Compare commits

...
495 Commits
Author SHA1 Message Date
Charlotte Weaver fcb1e988ad patch release 0.9.2 2019-05-07 12:49:22 -07:00
Charlotte Weaver a7bd76f676 fix #754 (#755) 2019-05-07 12:44:37 -07:00
Charlotte Weaver c12cb2424a release bugfix (#749)
* add __init__.py

* bump version
2019-05-06 10:16:36 -07:00
Bruce Martin 1471d6b214 release 0.9.0 (#746) 2019-05-04 08:41:56 -07:00
Charlotte Weaver 98b63fa9ea Moved to python threads (#745)
So we could use daemon threads
2019-05-03 14:48:48 -07:00
Bruce Martin 0aa0f641ab improve selection interaction with clip changes (#744)
* brush interactions with underlying dataframe updates improved

* improve comment

* reset selection state upon clip
2019-05-03 11:14:30 -07:00
Charlotte Weaver 77a4495b28 Update README.md (#743)
Fixes #702
2019-05-02 16:54:39 -07:00
Bruce Martin b521a17ffd improve column access speed for sparse matrices (#742)
* improve column access speed for sparse matrices

* add FAQ entry about data format performance

* add note about using --sparse flag for prepare command

* clean up for PR review

* Update docs/faq.md

Co-Authored-By: bkmartinjr <bruce@chanzuckerberg.com>

* improvements to big data faq
2019-05-01 15:44:01 -07:00
Colin Megill 86bf64e793 avoid overflow on clip dialogue (#740) 2019-05-01 15:41:54 -04:00
Charlotte Weaver 8a72010768 PyQt5 -> PySide2 (#738) 2019-05-01 09:03:16 -07:00
Sidney Bell a08e19bbd0 Clip continuous values based on percentile cutoffs (#672)
* Add numeric inputs for percentiles

* Define initial values for percentile cutoffs in world reducer

* add percentil to crossfilter dimensions

* worldEqUniverse now handles cloned worlds

* add Dataframe.mapColumns

* Wire up handlers for percentile inputs

* World reducer and stateManager know about continuousPercentileMin/Max

* Create world as universe clone (not pointer) to avoid clobbering vals

* Define basic actions for setting continuousPercentileMin/Max

* Under the hood, deal with percentiles between 0 and 1

* Move percentile inputs to visualization settings menu

* Fix padding for undo/redo buttons

* Trigger world rebuild from percentile actions

* BROKEN - pseudocode for clamping dataframe by percentiles upon world rebuild

* fix error handling on clip quantiles; start world clipping implementation

* more unclipped reorg

* rename crossfilter.percentile to quantile

* simplify schema access

* update continuous legend when scale changes

* update color cache when clip changes

* clip obs annotations and var data when clip quantile changes

* use own fromEntries

* fix tests

* stable non-finite float sort/search

* clarify comments

* fix syntax typo

* use new stand-alone clip

* clip expresssion data

* add select tests for non-finite scalars

* basic styles

* clip UI now requires explicit commit

* reset enable/disable accounts for clip percentiles

* better error messages

* fix bug in undo interaction with programatic min brush selection

* small refactoring

* support clipping of int data

* do not perform unnecessary summarizations

* improve caching of dataframe compiled columns

* add percentile precompute to Dataframe.summarize

* use Dataframe.summarize for clip percentiles

* remove obsolete quantile code from corssfilter

* histogram scale and label Y axis, add unclipped X range labels

* layout tweaks

* scatterplot now updates when clip changes

* improve comments

* remove debugging comment

* rework clip number entry validation for usability

* ui tweaks to histogram colors and layout

* enable undo/redo for clip user action

* refine UI on clip value entry

* api cleanup

* update confusing comment

* clarify purpose of isValidDigitKeyEvent

* fix misleading comment

* apply appropriate button-group classes; do not mix span and div

* variable name and comment changes suggested in PR review

* rename sort to sortArray; remove unused and dead code path

* naming changes suggested in PR review

* code review improvements for clarity

* more small changes from PR review

* lint fixes for PR review

* fix spelling error

* clarify that function performs in-place modification of world

* add comment to clarify intent of range operation

* fix bad indents in comments

* clean up __columnsAccessor comments and code

* improve comments around clipPredicate

* field name consistency

* improve comment on quantiles params
2019-04-30 16:20:10 -07:00
Charlotte Weaver 9f10d8095a GUI app (experimental) (#730)
* add default config

* first pass

* flake8

* cleanup

* first pass at using qthreads

* cleanup

* WIP

* better error handling

* improved UI

* bugfix

* fix merge bugs

* import order

* cleanup

* make gui requirements optional

* pr review requested changes

* Update server/gui/main.py

Co-Authored-By: csweaver <charlottesweaver@gmail.com>

* pr review request

* qt child class methods -> camelCase

* whitespace
2019-04-30 12:52:15 -07:00
Charlotte Weaver ea187f48e0 add scripts from cli (#680)
* add scripts from cli

* add warning when including scripts

* flake8 fixes

* confirm scripts injection
2019-04-22 12:30:57 -07:00
Charlotte Weaver 9c6273eb94 core library (#711)
* move app creation to function

* create engine without load

* flake 8 fixes

* cleanup original scanpy test

* add default config

* handle missing data

* test data changes

* unify update

* load data isn't static anymore

* make app a class
2019-04-22 12:24:07 -07:00
Justin Kiggins 1f735abe2b updates the roadmap & reorganizes the README (#712) 2019-04-18 13:10:03 -07:00
Colin Megill b878b0f93c Gene typeahead stale state (#714)
* reimplementing suggest

* resolve stale state
2019-04-17 14:03:10 -04:00
Charlotte Weaver ad0a3c939c faster ci (#713)
* parallelize docker build

* cache npm too

* testing skip install
2019-04-15 16:34:40 -07:00
Charlotte Weaver d581a0d460 color by gene smoketest (#707)
* add test for color by gene expression

gene expression and metadata color by are handled differently

* error on console.error

not just on thrown errors
2019-04-11 17:06:36 -07:00
Bruce Martin 9044b8d85d fix color-by regression in toggle (#705)
* fix color-by regression in toggle

* fix incorrect field reference
2019-04-11 12:47:38 -07:00
Charlotte Weaver 73852dd6f4 use flask's json (#703) 2019-04-09 12:59:09 -07:00
Bruce Martin 5e02408732 latent bug in color toggle (#701) 2019-04-09 10:50:35 -07:00
Sidney Bell 34e5a91dc6 Add calculate_qc_metrics to `prepare (#697)
* Calculate QC metrics

* Add QC metrics to prepare section of readme

* Add pointer to scanpy qc metrics function

* Don't explicitly pass qc flag as arg

* Add explicit toggle for run-qc/skip-qc

* Move qc metrics calculation to separate step/function
2019-04-09 08:56:07 -07:00
Bruce Martin 7275d9d4dc Graph selection state management and history bug fixes (#679)
* save graph selection in redux state

* fix old graph brush select regressions

* refactor graph brush selection to work with undo/redo

* update tests to match new crossfilter spatial select API

* graph selection state now in redux

* remove dead code

* sync graph selection with redux state; improvements to undoable machinery

* fix regression in undoable

* differentiate graph selection cancel from deselect action

* simplify calculation

* remove debugging code

* fix responsive repaint bug in graph selection tool

* undoable debugging and code cleanliness

* undoable action filter state now merges, rather than replaces

* improve comments

* add debounce to undoable action filter; improve comments and debug sanity check code

* comments

* fix undoable bug with clear scatterplot actions

* disable undoable debug flag

* cleanup API and comments around statemachine

* add test id attribute to lasso

* add better error handling for gene fetch requests
2019-04-08 15:53:12 -07:00
Charlotte Weaver c9a8e3ea42 Add manual UX tests (#699) 2019-04-08 13:23:17 -07:00
Bruce Martin 71505abe4c toggle color-by when repeatedly picked by the user (#696)
* toggle color-by when repeatedly picked

* remove debugging code
2019-04-08 10:57:47 -07:00
Sidney Bell c9a56fa73a Update scanpy version (#688)
Update to version 1.3.7
2019-04-04 12:16:30 -07:00
Charlotte Weaver 3167e38993 update docs to reflect python 3.7 support (#685) 2019-04-02 09:24:50 -07:00
Charlotte Weaver ce8326ddea bump version (#684) 2019-04-01 14:42:22 -07:00
Charlotte Weaver 5d4b11d287 Better smoke tests (#669) 2019-03-28 16:47:42 -07:00
Charlotte Weaver 096d477893 use Math.abs when calculating lasso area (#673) 2019-03-27 21:02:31 -07:00
Bruce Martin 153f240c43 brushable histogram brush selection responds to state updates (#670)
* add continuousSelection reducer and make histo respond to state changes

* correctly handle uninitialized state for brush move

* PR review tweaks
2019-03-25 15:02:39 -07:00
Gökçen Eraslan df46db965a bin/build-client.sh does not exist. (#671) 2019-03-25 10:26:27 -07:00
Bruce Martin 7ef5203564 Undo/redo (#659)
* immutable crossfilter

* first cut at reducer refactor with cascade model

* add initial redo/undo implementation

* small optimization

* integrate expression with history

* add tests for new reducers and fix a couple of small initialization bugs

* treat tiny lasso selections as a clear

* better function name for clarity

* fix undo for differential expression

* remove logging

* fix regression due to bad merge

* cleanup and comments for clarity

* improve undoable configuration for flexibility

* fix stale comments

* remove debugging code from production build

* rename categoricalSelectionState

* rename file

* improve comments
2019-03-22 14:53:48 -07:00
Bruce Martin 9420abfacc add Babel support for JS optioning chaining and nullish coallescing operators (#664) 2019-03-22 14:51:51 -07:00
Sidney Bell 16f93397ae Support diffmap and phate layouts. Explicitly handle embeddings with >2 components. (#662)
* Add diffmap and phate to supported embeddings

* Explicitly pull the first two components of any given layout
2019-03-21 19:06:05 -07:00
Colin Megill 016a4a422a Procedurally resize brush (#650)
* add on brush end event

* brush snap move

* resize brush
2019-03-20 14:46:20 -07:00
Bruce Martin 996b06cecc refactoring - immutable crossfilter (#647)
* immutable crossfilter

* PR review changes
2019-03-20 14:32:35 -07:00
Bruce Martin 571b7387e7 remove dead code from globals (#651) 2019-03-19 06:22:39 -07:00
Charlotte Weaver b6d468376a py37 fixes (#646)
* add python version for nightly build

* update numpy version required

older versions interact poorly on anaconda + python 3.7
2019-03-14 14:51:55 -07:00
Charlotte Weaver d8fc7e40a1 Support python3.7 (#645)
* Support python3.7

* add 3.7 env to travis
2019-03-14 12:08:46 -07:00
Charlotte Weaver e875ed739b More informative out-of-memory error (#644)
* more informative error message: memory error

* flake fix
2019-03-12 14:19:00 -07:00
Bruce Martin aa5ce4a2f1 correctly toggle group selection in categorical metadata (#640) 2019-03-12 10:49:29 -07:00
Bruce Martin 80969012c9 do not reset color scale when dismissing scatterplot (#637) 2019-03-12 10:49:01 -07:00
Bruce Martin caaee7e9bf do not reset color-by when subsetting world (#636)
* do not reset colors when subsetting to world

* revert diffexp state change
2019-03-12 10:48:35 -07:00
Charlotte Weaver f96fd36ecb Update test ui names (#638)
* Fixed changed testid

* Added debug mode for running tests
2019-03-12 10:17:09 -07:00
Bruce Martin 495dc55144 remove obsolete URL middleware (#639) 2019-03-11 16:03:33 -07:00
Colin Megill 8795f0f32c Add test ids and classes (#633)
* data test ids and classes

* suggest
2019-03-07 13:01:41 -08:00
Colin Megill a0f54b4871 Add menu (#631)
* add menu

* Added library versions to config

and tests

* add template version number
2019-03-06 11:56:46 -05:00
Charlotte Weaver 68dfbcc2eb Add force graph to docs and example dataset (#630) 2019-03-05 20:59:54 -08:00
Alex Wolf 82493d1019 Add forced directed graph drawing to allowed layout options (#626)
* added forced directed graph drawing layout options

* added line breaks for 120 character limit
2019-03-05 13:10:07 -08:00
Bruce Martin 3d6df0c044 implement improved disable/enable of Reset UI (#628) 2019-03-05 08:38:55 -08:00
Charlotte Weaver 2583016693 Fix bug where prod would automatically run after release stage 2 (#625)
* Fix bug where prod would automatically run after release stage 2

* fixed make release-burned
2019-03-01 20:20:16 -08:00
Bruce Martin d8e3721846 bumpversion to 0.7.0 (#623) 2019-03-01 12:25:30 -08:00
Bruce Martin a876740a3c create helper file for controls reducer (#615)
* initial dataframe commit

* initial dataframe port of core app

* rename variables for clarity

* remove unused import

* comment out unused code

* fix array handling bug in crossfilter dimension creation

* allow creation of empty dataframes

* handle non-existent columns

* handle non-existent columns

* revise tests for new dataframe

* comments for clarity

* comments for clarity

* generate bulk add placeholder with real gene names

* fix bug in gene name adding

* more dataframe unit tests

* fix bug - subset from current world, not universe

* put cut and pasted code into a single function

* improve caching of crossfilter

* remove cascading update bug from graph

* more performance work

* improve state handling for scatterplot

* performance optimization of critical path

* add column summarization

* dataframe utils

* add callOnceLazy

* fix tests

* minor updates found during review

* fix misspelling

* remove RESTv02 from function names

* comment cleanup

* cut/icut col parameter defaults to null

* break up large test

* improve tests and comments on dataframe at/has functions

* add Dataframe withCol/dropCol

* expression varData now stored in a dataframe

* dead code cleanup

* use dataframe.summarize()

* test cases for Dataframe.col.summarize

* update test cases for new dataframe summarize

* improve naming

* use new hasCol API

* add comments

* add more Dataframe.withCol tests

* add ability to specify row index in cut operation

* retire subsetVarData function

* correctly handle expression subsetting

* lint and improve comments

* rename cut to subset

* create helper file for controls reducer
2019-02-28 09:21:19 -08:00
Bruce Martin e7ad6f5d1c [WIP DO NOT MERGE] correctly display graph legend for negative continuous metadata (#620)
correctly display graph legend for negative continuous metadata
2019-02-28 09:08:19 -08:00
Bruce Martin 2f1facaafb [WIP DO NOT MERGE] suppress display of continous annotation without a finite extent (#618)
* initial dataframe commit

* initial dataframe port of core app

* rename variables for clarity

* remove unused import

* comment out unused code

* fix array handling bug in crossfilter dimension creation

* allow creation of empty dataframes

* handle non-existent columns

* handle non-existent columns

* revise tests for new dataframe

* comments for clarity

* comments for clarity

* generate bulk add placeholder with real gene names

* fix bug in gene name adding

* more dataframe unit tests

* fix bug - subset from current world, not universe

* put cut and pasted code into a single function

* improve caching of crossfilter

* remove cascading update bug from graph

* more performance work

* improve state handling for scatterplot

* performance optimization of critical path

* add column summarization

* dataframe utils

* add callOnceLazy

* fix tests

* minor updates found during review

* fix misspelling

* remove RESTv02 from function names

* comment cleanup

* cut/icut col parameter defaults to null

* break up large test

* improve tests and comments on dataframe at/has functions

* add Dataframe withCol/dropCol

* expression varData now stored in a dataframe

* dead code cleanup

* use dataframe.summarize()

* test cases for Dataframe.col.summarize

* update test cases for new dataframe summarize

* improve naming

* use new hasCol API

* add comments

* add more Dataframe.withCol tests

* add ability to specify row index in cut operation

* retire subsetVarData function

* correctly handle expression subsetting

* lint and improve comments

* rename cut to subset

* suppress display of continous annotation withont a finite extent

* fix botched merge

* more fix of botched merged
2019-02-28 09:05:48 -08:00
Bruce Martin ffd6273419 Dataframe, part deux - add varData and summarize() (#608)
* initial dataframe commit

* initial dataframe port of core app

* rename variables for clarity

* remove unused import

* comment out unused code

* fix array handling bug in crossfilter dimension creation

* allow creation of empty dataframes

* handle non-existent columns

* handle non-existent columns

* revise tests for new dataframe

* comments for clarity

* comments for clarity

* generate bulk add placeholder with real gene names

* fix bug in gene name adding

* more dataframe unit tests

* fix bug - subset from current world, not universe

* put cut and pasted code into a single function

* improve caching of crossfilter

* remove cascading update bug from graph

* more performance work

* improve state handling for scatterplot

* performance optimization of critical path

* add column summarization

* dataframe utils

* add callOnceLazy

* fix tests

* minor updates found during review

* fix misspelling

* remove RESTv02 from function names

* comment cleanup

* cut/icut col parameter defaults to null

* break up large test

* improve tests and comments on dataframe at/has functions

* add Dataframe withCol/dropCol

* expression varData now stored in a dataframe

* dead code cleanup

* use dataframe.summarize()

* test cases for Dataframe.col.summarize

* update test cases for new dataframe summarize

* improve naming

* use new hasCol API

* add comments

* add more Dataframe.withCol tests

* add ability to specify row index in cut operation

* retire subsetVarData function

* correctly handle expression subsetting

* lint and improve comments

* rename cut to subset

* changes based on PR review
2019-02-28 08:34:22 -08:00
Charlotte Weaver 2bae696986 Smoke tests (#604)
smoke tests
2019-02-27 15:46:58 -08:00
Bruce Martin 6b33315cbe Dataframe (#576)
* initial dataframe commit

* initial dataframe port of core app

* rename variables for clarity

* remove unused import

* comment out unused code

* fix array handling bug in crossfilter dimension creation

* allow creation of empty dataframes

* handle non-existent columns

* handle non-existent columns

* revise tests for new dataframe

* comments for clarity

* comments for clarity

* generate bulk add placeholder with real gene names

* fix bug in gene name adding

* more dataframe unit tests

* fix bug - subset from current world, not universe

* put cut and pasted code into a single function

* improve caching of crossfilter

* remove cascading update bug from graph

* more performance work

* improve state handling for scatterplot

* performance optimization of critical path

* add column summarization

* dataframe utils

* add callOnceLazy

* fix tests

* minor updates found during review

* fix misspelling

* remove RESTv02 from function names

* comment cleanup

* cut/icut col parameter defaults to null

* break up large test

* improve tests and comments on dataframe at/has functions
2019-02-22 11:31:34 -08:00
Bruce Martin 57c4e9ff33 Flatbuffer cleanup (#598)
* dead code and route removal

* more dead code cleanup

* fix scanpy_engine tests

* lint

* add missing catch in filter parsing

* update scanpy NaN tests

* more fbs tests and dead test removal

* remove forced default for content type negotiation

* bit of cleanup

* more fbs test cleanup

* lint

* remove swagger

* swagger cleanup

* lint

* correctly handle lack of templates

* more dead code removal

* remove unused files

* fix dev build

* lint
2019-02-19 08:50:29 -08:00
Charlotte Weaver 4e67c645f8 bumped version (#602)
0.6.0 was burned on pypi
2019-02-14 10:00:16 -08:00
Charlotte Weaver 8b28d51dfa bump version (#601) 2019-02-13 16:29:14 -08:00
Charlotte Weaver 40ad283107 create server testing doc (#592) 2019-02-13 15:35:45 -08:00
Isaac Virshup 0f8d7a55de Set API path based on access address (#568)
* Make api paths relative

* Remove request import

* Set publicPath to be relative
2019-02-11 09:01:37 -08:00
Sidney Bell b6f946ec8a Add note about installing hdf5 to FAQ (#581) 2019-02-08 14:56:16 -08:00
Colin Megill dbb3a309a9 Lasso (#586)
* lasso working

* break out invert into own function

* action

* add spatial dimension to crossfilter, in support of polygon lasso

* improve comments on new dimension API

* lasso vs zoom
2019-02-08 11:48:51 -08:00
Bruce Martin 2e9525741f doc divergence warning (#591) 2019-02-08 11:00:51 -08:00
Charlotte Weaver 585a5808b9 check if accept type in content type string (#589) 2019-02-08 09:41:24 -08:00
Bruce Martin 6f464f4f92 package dependency updates (#585)
* lint

* update dev-related package dependencies
2019-02-06 12:57:09 -08:00
Colin Megill 08ea7d5137 Better input validation (#580) 2019-02-05 10:44:59 -05:00
Charlotte Weaver ad9be3cdd7 remove build-dev from .gitignore (#583) 2019-02-04 14:47:28 -08:00
Charlotte Weaver f737cc4ee4 Build improvements (#577) 2019-02-04 14:15:35 -08:00
Charlotte Weaver 1103272b95 De-dupe -d CLI option alias (#575)
removed from debug, diffexp gets to keep it
2019-01-30 16:11:21 -08:00
Colin Megill 2df7161cd8 Bulk add genes (#567)
* bulk add

* cleanup
2019-01-29 16:26:09 -05:00
Charlotte Weaver d31c05c970 Add backed script to package.json (#566)
* QOL script for FE devs to get & launch the backend

* ensure python3.6

* changed name to backend-dev
2019-01-29 11:41:05 -08:00
fionagriffin 07db2eb3ee Update data.md (#544) 2019-01-29 10:31:31 -08:00
Justin Kiggins a6d2a2e119 updates link to scanpy recipe docs. fixes #564 (#565) 2019-01-28 10:00:29 -08:00
Justin Kiggins f87e4bfbd3 home page fix (#551)
* explicit site.url in config

* infra for custom css

* moves home item to html layout

* adds baseurl to site config
2019-01-28 09:13:09 -08:00
Charlotte Weaver ecaa32cfb2 bump version (#563) 2019-01-17 17:14:44 -08:00
Charlotte Weaver 10693b08cc Add __init__ file so fbs can be imported (#562) 2019-01-17 17:11:31 -08:00
Charlotte Weaver b02361a6bf bump version (#560) 2019-01-17 16:54:25 -08:00
Colin Megill 3fa888c6d4 user defined gene sets scatterplot, remove works (#556) 2019-01-16 03:37:08 -05:00
Colin Megill a42c9aca1c resetting interface spinner but locked interface (#555) 2019-01-16 03:36:35 -05:00
Charlotte Weaver 394da40bea Doc formatting improvements (#549) 2019-01-10 15:09:02 -08:00
Bruce Martin 5d60505407 remove --nan-to-num CLI parameter (#548)
* remove --nan-to-num CLI parameter

* factor tests better

* lint - remove unused variables
2019-01-10 15:03:03 -08:00
Colin Megill f876a0091a Set theme jekyll-theme-cayman 2019-01-10 12:12:58 -05:00
Colin Megill 528eb5d172 Set theme jekyll-theme-minimal 2019-01-10 12:10:11 -05:00
Bruce Martin b90447c387 binary wire format with flatbuffers (#509)
* first flatbuffer schema

* do not lint auto-generated files

* add flatbuffers package

* add flatbuffer module

* wire up /data/X/T route

* use flatbuffers for matrix data fetc

* clarity and comments

* add flatbuffer layout route

* clean up obsolete code

* fix tests

* move flake8 config to setup.cfg

* add comments

* lint

* rework layout routes for fbs

* add more type support to fbs

* lint

* add flatbuffer support for annotations

* function name improvements

* fix botched merge with master

* remove unused import

* route cleanup for flatbuffers

* rename function for clarity

* add missing globals to Jest tests

* fix client JS tests

* fix routes for Python tests

* comments for clarity

* non-finite floating point hardening

* more non-finite number handling

* lint

* fix tests for summarizeAnnotations

* harden diffexp calculation against FP errors

* cleanup unused code

* lint

* add encoding tests for flatbuffers

* application type specified as strings

* fix spelling error

* improve variable names

* add note about documentation gap

* rename FBS DataFrame to Matrix
2019-01-09 14:26:05 -08:00
Colin Megill 42e25a1a1f Async on long running operations (#543)
* compute differential button

* add user gene loading state to reducer

* create input group, consume usergeneloading

* add loading state
2019-01-07 16:45:47 -05:00
Justin Kiggins eceab377f7 cleans up landing page (#541) 2019-01-04 14:20:23 -08:00
Colin Megill b0daeb3a9b disable cell set buttons (#538) 2019-01-03 20:05:09 -05:00
fionagriffin 7499fca251 add datasets to data.md (#540)
added links and general info for interesting example datasets to use with cellxgene
2019-01-03 16:35:37 -08:00
GenevieveHaliburton 05a5a945bb Landing page faq comments (#536)
* add to faq, clean up titles

* h4 headings for readibility

* Ordering, add section on errors
2019-01-03 14:07:38 -08:00
Colin Megill d3c96087b2 Add GIFs to docs (#537)
* Add gif files

* rename gif for clarity

* add gifs

* reorder

* resize
2019-01-03 15:24:33 -05:00
Bruce Martin cfcf16aa69 [DO NOT MERGE] performance improvements in color-by feature (#535)
performance improvements in color-by feature
2018-12-27 14:05:49 -08:00
Justin Kiggins bc59074300 new docs (#525)
* new docs

* moves development docs to dev_docs
2018-12-20 14:28:05 -08:00
Colin Megill 56f9bc543e Set theme jekyll-theme-minimal 2018-12-20 12:05:08 -05:00
James Taylor f2eb2cad82 Use url_root from Flask rather than hardcoding hostname. (#520)
Allows running in a container on a remote host, should also allow running
behind a proxy with a url prefix.
2018-12-19 16:20:26 -08:00
Justin Kiggins bd523280a4 improved quickstart (#523) 2018-12-19 13:38:30 -08:00
Justin Kiggins fc45fb3899 creates FAQ page in docs (#522)
* creates FAQ page

* revise index.md

* tweaks
2018-12-19 13:18:16 -08:00
Charlotte Weaver f0f7200f0b Move jsonification to engine level (#511) 2018-12-18 21:14:24 -08:00
fionagriffin 6af7708d62 update index.md (#519)
ready for features
2018-12-18 11:10:29 -05:00
Colin Megill a297ea30e2 bump version (#518) 2018-12-17 14:30:34 -05:00
Colin Megill 0ef6c36f4c Cluster Occupancy (n dim cube) (#513)
* create occupancy component

* occupancy working

* centering, width, flex spacing

* add is-number

* extend sort to cover string + int

* proof of concept sorted occupancy

* handle undefined occupancy entry, cleanup

* only render occupancy when colorby is cat

* cleanup unused vars

* cleanup
2018-12-17 14:19:48 -05:00
Justin Kiggins aaa60bc303 new signup url (#516) 2018-12-14 16:32:41 -08:00
fionagriffin 7d40d89fd8 Update index.md (#507)
Added content from readme
2018-12-14 15:19:21 -08:00
Charlotte Weaver 46ad346df1 Clarify that we do not support python 3.7 (#512)
* Update setup.py

* Readme: no python 3.7

* specify 3.6 in venv

* clarify 3.7 error

we will support it eventually
2018-12-13 15:15:56 -08:00
Charlotte Weaver 83154577e4 Black -- formatter for python (#508)
* Add black

* use black to format code

* Black version
2018-12-12 09:44:47 -08:00
Charlotte Weaver a847951658 Update slack link (#506) 2018-12-11 10:20:22 -08:00
Charlotte Weaver 24af6efbcb Add Docker file (#505)
* Add docker support

* Add readme

* minor fixes
2018-12-11 10:18:10 -08:00
Colin Megill 126cac833a Rename README.md to index.md 2018-12-11 12:09:16 -05:00
Colin Megill fc272dc42e github pages test 2018-12-11 12:05:14 -05:00
Colin Megill d3a0d66139 Set theme jekyll-theme-architect 2018-12-10 18:16:40 -05:00
Charlotte Weaver 9604231a2a Add alternative to install [louvain] extra requires (#500)
* louvain install by hand

* reworded
2018-12-10 14:46:55 -08:00
Jeremy Freeman 6ea3b7f3cf use collapsable details to improve FAQ formatting (#503) 2018-12-07 21:07:57 +01:00
Charlotte Weaver 0d0a32f272 Added PR guidelines (#492)
* Added PR guidelines

Added document to cover guidelines for creating, reviewing and merging PRs.

* fixed typo

* another typo

* grammar

* Remove 5's title

The subheading is clearer as a top-level
2018-12-06 13:43:10 -08:00
Bruce Martin 48e0ea542b clarifications to release recipe (#499)
* clarifications to release recipe

* fix spelling errors
2018-12-05 16:58:53 -08:00
Bruce Martin 95fd20b405 bump version to 0.3 (#498) 2018-12-05 14:35:17 -08:00
Charlotte Weaver 5c6cc597c8 Default warning in debug mode (#497) 2018-12-05 13:38:34 -08:00
Bruce Martin 3bfeadc2b9 improve handling of non-finite floating point values (#490)
* improve HTTP error reporting

* generate standards-compatible JSON

* add --nan-to-num work-around for non-finite floating point values

* lint

* update tests

* correctly set Infinities to min/max

* REAMDE update for --nan-to-num

* define constant for repetitive warning message

* clarify where NaN errors will occure
2018-12-04 14:56:16 -08:00
Bruce Martin 296ed752fa Improved summary counts of annotation values (#478)
* convert annotation summary to a Map

* add 2d annotation count summary

* add memoization on 2D annotation counting

* add tests for annotation summarization

* fix import/exports

* rename WorldOps to WorldUtil

* rename WorldOps to WorldUtil

* add comment
2018-12-03 09:15:03 -08:00
Charlotte Weaver 1e66ec2b89 Update scikit learn (#487)
They finally fixed their cloud pickle issue
2018-11-30 12:02:28 -08:00
Charlotte Weaver 4a50f0ed46 External host option (#481)
* allow specifying host ip instead of listen all

* flask serving

* Limit to ip addresses only
2018-11-29 17:01:49 -08:00
Bruce Martin af0d1f6fb2 issue #480 workaround (#484)
* only load annotation var names

* remove incorrect usage of var annotation data

* temporary workaround for issue #480

* lint

* issue warnings only once per item
2018-11-29 16:33:21 -08:00
fionagriffin a83ec60308 add FAQs to readme (#482)
Added question and answer to FAQ; added link to scanpy documentation on scientific recipes.
2018-11-29 13:59:10 -08:00
Charlotte Weaver 76c0cbf16b loose -> lose (#479) 2018-11-28 13:07:10 -08:00
Charlotte Weaver 455d987398 Add custom format for warnings (#477)
[cellxgene] Warning: <message>
2018-11-28 12:47:11 -08:00
Colin Megill 00b9a8fa72 Fuzzy search on genes (#473)
* proof of concept blueprint typeahead

* poc with fuzzysearch lib

* typeahead adds gene on enter

* add gene on menu click

* typeahead clears correctly

* cleanup
2018-11-28 09:50:02 -08:00
Charlotte Weaver 28f5263c88 bump version (#475) 2018-11-27 15:43:19 -08:00
Bruce Martin 933fac5fff Allow float64 to down-cast to float32 (#472)
* warn if annotations will down-cast to float32

* correctly handle falsey data values
2018-11-27 11:17:30 -08:00
Charlotte Weaver 0c26f227fe bump version (#470) 2018-11-26 15:38:47 -08:00
Marcus Kinsella 2b90c747f5 Fix readme images on PyPi (#467)
But this time actually do that
2018-11-26 15:27:05 -08:00
Charlotte Weaver 43a4e087ef Fix formatting issues (#468)
- Fix step 8 bullets
- Appropriate capitalization
- Add link to release notes doc
2018-11-26 13:52:00 -08:00
Charlotte Weaver f2612707bb bumped version (#466) 2018-11-26 11:42:56 -08:00
Bruce Martin 138d30909a improve type handling for non-string annotation data (#465)
* improve type handling for non-string annotation data

* improve clarity of code
2018-11-26 10:32:29 -08:00
Charlotte Weaver 2b5094665f version 0.2.0 (#456) 2018-11-16 15:12:54 -08:00
Jeremy Freeman a81258bc0d remove printing (#455) 2018-11-16 14:45:06 -08:00
Bruce Martin 141f802824 differential expression improvements (#452)
* add cutoff for low expression genes in topN selection

* remove debugging printfs

* change cli param name for diffexp cutoff

* change CLI param name

* second try at diffexp - using lfc sort with pval cutoff

* use lfc cutoff

* update comments to match code; cap p-value adjustment to max of 1

* lint

* explain diffexp in readme

* add link

* add diffexp-lfc-cutoff to test config

* update test to match revised diffexp spec

* fix latent bug in GET arg handling that was breaking tests

* lint

* comment cleanup

* fix variance overestimation so it is symmetric

* lint
2018-11-16 14:44:56 -08:00
Charlotte Weaver 3475f3f12e Update release_process.md (#443)
* Update release_process.md

* Clarified release process
2018-11-16 14:36:43 -08:00
Jeremy Freeman dff2526077 warm to cool (#454) 2018-11-16 14:33:32 -08:00
Marcus Kinsella 2b4aa92f67 Use externally-reachable image urls (#453)
This is needed for the images to show up in pypi.
2018-11-16 13:02:49 -08:00
Jeremy Freeman 3151306d7e readme updates (#436)
lots of updates to the readme to: improve scientific and technical clarity, reflect all recent changes to the CLI (especially the addition of prepare), reflect all recent changes to our installation, improve explanation of how to handle a few different kinds of data, and expand instructions on contributing and developing
2018-11-15 18:18:30 -08:00
Colin Megill ef80c8df2a Change color scales to rainbow (#449)
* rainbow

* eslint

* remove comments
2018-11-15 18:17:55 -08:00
Charlotte Weaver 1a5f49239a Release 0.0.4 (#447)
* 0.0.3 bump

* release 0.0.4
2018-11-15 16:43:59 -08:00
Charlotte Weaver dfc04d8de5 Add requirements to manifest (#445) 2018-11-15 11:56:22 -08:00
Marcus Kinsella 523048de43 Set long_description_content_type (#439)
This _should_ make it look more attractive on pypi.
2018-11-15 11:19:34 -08:00
Charlotte Weaver 07cda497d9 build bug fixes (#438)
* Fixes compatibility conflict with numpy version and anndata version #434

* Forces description to be read as unicode

fixes #435
2018-11-14 14:21:02 -08:00
Bruce Martin 00a68276a2 diffexp performance & UX improvements (#431)
* new diffexp REST API spec

* new diffexp REST API; faster diffexp and dataframe slicing

* first draft of fast diffexp

* convert variance calculation to two-pass method

* lint

* update front-end use of API

* fix typo in spec

* disable content compression

* catch index filter format errors

* clean up of dead code

* resolve PR review comments
2018-11-14 12:51:24 -08:00
Charlotte Weaver bc0cecbd1c Release Test (#427)
* bump-update

* Release Test!
2018-11-14 11:03:52 -08:00
Colin Megill dbff824854 Viewport fills entire screen (#430)
* full pane webgl and svg

* fixes for full pane selection and centering

* force graph remount, clear brush state
2018-11-13 11:45:38 -05:00
Colin Megill 9decf134e0 fixed scatterplot infinite render (#429)
* add more to state from componentDidMount

* always brush

* destructure

* move render to function

* fixed scatterplot infinite render

* remove logging
2018-11-09 20:28:22 -05:00
Charlotte Weaver 47ce0cfc49 Update release_process.md (#428) 2018-11-09 16:58:01 -08:00
Charlotte Weaver 018e3ae5c4 bump-update (#426) 2018-11-09 14:17:07 -08:00
Charlotte Weaver aa29c91470 Prepare fixes (#416)
* Error on overwrite=false but no output file

* Add prepare requirements to requirements.txt

* no saved output error -> warning

* Catch clustering import error

* lint fixes

* Fix missing output message
2018-11-09 13:51:30 -08:00
Charlotte Weaver dd56d0937c fix anaconda build (#421)
* fix anaconda build

* Added link for TKAgg

* Add matplotlib to requirements

We are pulling it in through scanpy, but since we are importing it directly we should include it explicitly
2018-11-09 13:22:45 -08:00
Charlotte Weaver dfcef6075e add updating the cli version (#420)
* add updating the cli version

* remove bdist_wheel -- no platform specific needs
2018-11-09 12:52:10 -08:00
Charlotte Weaver b4edd56735 Requirements updates (#422)
* Unpin requirements that do not need to be fixed

* add twine to dev requirements

* Add versions to dev too
2018-11-09 12:50:15 -08:00
Charlotte Weaver 1dc80145dc Add bumpversion (#423)
* Add bumpversion

* Update readme for bumpversion
2018-11-09 10:01:02 -08:00
Bruce Martin 6916aec6c3 update package dependencies (#417) 2018-11-07 13:58:21 -08:00
Bruce Martin 9f7e1f1cb0 use quicksort instead of default system sort (#413) 2018-11-07 13:35:00 -08:00
Bruce Martin 121360c0a5 remove extraneous package-lock.json file (#412) 2018-11-07 13:34:41 -08:00
Charlotte Weaver 7d5c054b90 Launch validation (#414)
* WIP

* Cleanup

* Validation

* typo

* Max category limit 100 -> 500
2018-11-07 13:01:13 -08:00
Bruce Martin a9189017c7 first draft of release process (#408)
* first draft of release process

* additional release process changes

* specify semver
2018-11-06 07:17:35 -08:00
Bruce Martin 2d5bc9d0c9 Performance work, plus fix #405 (#406)
* remove memoization

* update to flash 1.0.2; turn on threading

* stand-alone helper routines for array slicing

* fix issue #405

* reset diffexp state when world changes

* performance work in dimension creation; fix world slicing bug

* update tests to match new state mgmt api

* update flask

* do not make dimensions for useless annotations

* update test to match optimizations
2018-11-05 13:57:58 -08:00
Bruce Martin 24a8a23355 add acknowledgements to README (#407) 2018-11-05 10:17:40 -08:00
Colin Megill 0dcc17d6d5 rerender axes on regraph (#403)
* remove cells loading now that we have toast

* rerender histogram axes

* remove logging
2018-11-05 09:29:49 -05:00
Jeremy Freeman e2d864a336 integrate prepare cli (#401)
* move prepare into main CLI as subcommand

* naming and formatting tweaks to better match launch subcommand
2018-11-03 10:48:05 -04:00
Jeremy Freeman 0c8a07ac13 CLI refactor (#396)
* refactor cli to improve ux and enable easy incorporation of prepare as a subcommand

* switches to use click, which removes some boilerplate and gets us some improved ux for free

* changes the entry point for the cli

* changes the name of the browser option to --open and makes the default false
2018-11-02 10:52:50 -04:00
Bruce Martin 6e46a65c32 fix add gene action format bug (#398) 2018-11-01 14:24:49 -07:00
Bruce Martin 9d7754e31e Improve front-end HTTP error handling (#397)
* UI for HTTP error handing in diffexp route

* HTTP error handling for other routes

* clean up toasters
2018-10-30 19:02:11 -07:00
Bruce Martin b181751493 add --obs-names and --var-names CLI params (#371)
* add --obs-names and --var-names CLI params

* fix lint

* performance improvements in scanpy engine

* fix lint

* fix typo

* correctly handle sparse formats in diffexp

* fix diffexp and 1d slicing

* diffexp uses t-stat, not pval; clean up arg handling

* make _slice a static method

* revise scanpy tests to match new API
2018-10-30 14:07:38 -07:00
Bruce Martin 01369801fc Docs: add parameters to /config (#357)
* add parameters to /config

* add real parameters
2018-10-30 11:04:30 -07:00
Bruce Martin bc15e9229f update config params to match REST API spec (#393) 2018-10-30 11:03:40 -07:00
fionagriffin 4f888505ee Fixing typo in readme (#390)
The "Run" section had a typo for the filename of the example dataset
2018-10-30 10:49:07 -07:00
Charlotte Weaver 26d222cd75 Move computation options to app (#386)
no need to import scanpy engine for CLI help so speeds up time response
2018-10-26 17:07:21 -07:00
Jeremy Freeman 75ca14e4aa add CLI tool for dataset preparation using scanpy (#364)
* add prepare cli

* fix handling of user path

* fixes for linter

* add flags and options for handling obs and var names

* add prepare cli

* fix handling of user path

* fixes for linter

* add flags and options for handling obs and var names

* address review requests
2018-10-26 16:44:41 -07:00
Colin Megill ed66ae6e4a tooltips, copy, scatterplot tray (#383)
* tooltips, copy, scatterplot tray

* reset interface

* copy
2018-10-26 19:21:39 -04:00
Charlotte Weaver 6c23a72e5f CLI renaming and phrasing (#385)
* Minor naming and phrasing changes from UX review

* category-selection-limit -> max-category-items
* Indicate load may taking a long time
* program -> command (for launch, prepare)
* debug -> verbose
* flask-debug -> debug

* Developer mode for debug

verbose on
open browser off

* move examples from epilogue to prefix
2018-10-26 15:59:34 -07:00
Charlotte Weaver 16b0741a6c Hide stack traces unless in debug mode (#384) 2018-10-26 13:36:53 -07:00
Colin Megill d221d76be6 test for world, graph border, legend label (#378)
* test for world, graph border, legend label

* typo
2018-10-25 12:16:55 -04:00
Charlotte Weaver 94f95d6565 CLI Launch (#366)
* Scanpy engine now required

Without the --engine param we need to error if scanpy engine cannot be imported rather than waiting for all engines

* CLI options and help matches proposal

(but not all options hooked up yet)

* Flesh out top level args

* Move computation args to engine

* CLI input file (#374)

* Fix test command

(tests still won't work)

* Input is file instead of directory
- also renamed example file

* Csweaver/debug (#376)

* Respect debug flag for logging flask calls

* Add loading messages

* max categories (#377)

* Add max categories

* Rename max_categories to category_selection_limit

* ensure whole numbers
2018-10-24 19:28:59 -07:00
Charlotte Weaver 02b2349807 Pin scikit-learn to fix imp error (#375) 2018-10-24 14:46:34 -07:00
Charlotte Weaver 9e5114a730 Ignore built media files (#372) 2018-10-24 13:02:17 -07:00
Charlotte Weaver 85f44ccabf Converts NaNs in pval to 1s (#365) 2018-10-24 10:57:29 -07:00
Colin Megill 7aa390b5a1 left sidebar improvements (#368)
* left sidebar improvements

* lint
2018-10-24 10:21:18 -04:00
Bruce Martin 3556bd11f5 fix lint (#369) 2018-10-24 07:12:51 -07:00
Charlotte Weaver 4ec938c166 Prefix client build (#367) 2018-10-23 15:56:57 -07:00
Bruce Martin b74c9e15c6 build fixes (#362)
* add production web build to travis tests

* remove old builds before starting new build
2018-10-22 13:54:11 -07:00
Colin Megill 96675f806c hotfix duplicate globals declaration 2018-10-22 16:39:41 -04:00
Colin Megill df671192be Begin integrating blueprint (#359)
* toast test

* modify webpack build to allow for global CSS; use CSS modules to scope component styling

* remove heatmap

* add label to index resets

* swapping out buttons for blueprint

* adding constants to globals

* swapping out blueprint buttons

* title position, sidebar improvements

* sidebar styles

* adding toasts for validation errors

* zebra section trial

* integrating blueprint, switching buttons over, breaking out scatter

* clean up console
2018-10-22 15:46:37 -04:00
Bruce Martin 0c271bc815 categorical metadata performance work (#356)
* do not create unecessary option state for categories we will not display

* performance improvements for categorical metadata handling
2018-10-22 09:56:41 -07:00
Charlotte Weaver efb55a6332 Renaming bind-all option to listen-all (#353)
See discussion here https://docs.google.com/document/d/1tcAvODhdlUUAOJPHoGQB3fWOPmnw3XjK7MVhDRwNUnk/edit#
2018-10-19 15:26:19 -07:00
Charlotte Weaver 2a3577f32b flask debug as hidden option (#352)
* Remove flask debug flag

* Add debug flag as a hidden option to keep reload on change for developers
2018-10-19 10:24:15 -07:00
Bruce Martin 5dea303bcd handle case of excessively large number of options in categorical met… (#350)
* handle case of excessively large number of options in categorical metadata
* move max category options count into globals
2018-10-18 18:07:39 -07:00
Charlotte Weaver 06f402ab0a Do not calculate layout, used saved layout instead (#343)
* Do not calculate layout, used saved layout instead

See for rationale: https://docs.google.com/document/d/1HJFvbdDHxxgkCW0DZzdTZ9CUMgc2ef2rQFukATBQWvE/edit

* Error handling for when layout has not been precomputed

* Server error (500) not client error (400) for unprepared data
2018-10-18 11:51:38 -07:00
Bruce Martin 2616b5c185 finish tests for world (#340)
* Create Release_Validation_Recipe.md

* nameCreators test and better error handling

* add test for createObsDimensionMap

* add last set of tests for world module
2018-10-18 09:54:57 -07:00
fionagriffin f33a3cd1a3 Create Release_Validation_Recipe.md (#337) 2018-10-18 09:27:32 -07:00
Charlotte Weaver 7c8b89eba3 Launch browser to cellxgene (#325)
* Launch browser to cellxgene

Also added  --no-launch command line parameter

* Don't launch browser for tests

* rename no-launch to no-open

* Rejigger -no-open to positive destination

so later logic looks cleaner
2018-10-17 16:46:56 -07:00
Bruce Martin f9f43c42c0 more lint - components (#336)
* more lint

* fix typo introduced in delinting

* more lint

* remove dead code

* lint fixes

* lint
2018-10-17 07:17:51 -07:00
Colin Megill 952578680a Remove heatmap, add diffexp info to histograms (#339)
* Create Release_Validation_Recipe.md

* compose middle ware for redux devtools

* update package.json and lock

* remove heatmap, add to left side bar

* redux devtools extension
2018-10-16 16:29:20 -07:00
Bruce Martin 76ec29734a Performance work (#334)
* range encode filter range lists

* speed up data load

* add comment on scanpy read params

* update to latest scanpy/anndata

* performance improvments in data loading

* fix typo

* work around scanpy bug

* remove debugging print statements
2018-10-16 15:49:40 -07:00
Bruce Martin 6b0aafd27c package dependency cleanup (#330)
* update npm packages.  babel 7, redux 4, etc

* remove obsolete babel config file

* use correct icons
2018-10-15 17:36:13 -07:00
Colin Megill 65f5244ff7 add name getter to world test 2018-10-15 11:20:52 -07:00
Colin Megill e7642e6a8f remove log 2018-10-15 11:20:52 -07:00
Colin Megill 8114d7565a dispose and delete 2018-10-15 11:20:52 -07:00
Colin Megill 8e08534d42 user defined gene 2018-10-15 11:20:52 -07:00
Colin Megill 2b77f8146b namespacing 2018-10-15 11:20:52 -07:00
Colin Megill e1a437514e clean up log 2018-10-15 11:20:52 -07:00
Colin Megill 48fa221ad9 diffexp, regraph, brush 2018-10-15 11:20:52 -07:00
Colin Megill fff331e1f0 stub additional validation for gene name 2018-10-15 11:20:52 -07:00
Colin Megill 8d07b400be adding and removing user defined genes 2018-10-15 11:20:52 -07:00
Colin Megill 5b2c07f105 clear differential reducer 2018-10-15 11:20:52 -07:00
Colin Megill ca35ae0b40 cleanup in histogram 2018-10-15 11:20:52 -07:00
Colin Megill 1cf9fc76b0 break out differential genes from user defined 2018-10-15 11:20:52 -07:00
Colin Megill a42f9f83f5 clear differential expression button interface 2018-10-15 11:20:52 -07:00
Colin Megill 9de44e6fd4 clear differential expression reducer 2018-10-15 11:20:52 -07:00
Colin Megill ca37483447 remove old heatmap webgl code 2018-10-15 11:20:52 -07:00
Colin Megill dd1b9229fc access varDataCache with kvCache.get 2018-10-15 11:20:52 -07:00
Colin Megill 73f55cb9f8 obs index to index 2018-10-15 11:20:52 -07:00
Colin Megill 8aa75458ed brushable histogram renders genes 2018-10-15 11:20:52 -07:00
Colin Megill 0c8f4ed824 rename to createVarDimension 2018-10-15 11:20:52 -07:00
Colin Megill 5165297e2a position tweak 2018-10-15 11:20:52 -07:00
Colin Megill 9c8e0e76ad remove export 2018-10-15 11:20:52 -07:00
Colin Megill 693dc7be12 switch to roboto condensed for space 2018-10-15 11:20:52 -07:00
Colin Megill 090bad040e worldIndex returns NaN 2018-10-15 11:20:52 -07:00
Colin Megill a297956e69 add world helper method for var 2018-10-15 11:20:52 -07:00
Colin Megill b0603dbed7 create dimension for arbitrary gene 2018-10-15 11:20:52 -07:00
Colin Megill 30069c0e66 export request genes action for use from components 2018-10-15 11:20:52 -07:00
Colin Megill 2c74291ee9 add gene expression section to left sidebar 2018-10-15 11:20:52 -07:00
Bruce Martin 329cf64a49 remove webpack hot load, react hot load and react redbox (#326)
Fixes #176 and #290
2018-10-15 08:47:15 -07:00
Bruce Martin c7e6f54450 cleanup middleware pre-ship (mostly lint) (#329)
Fixes #180

URL middleware unused, but retained in expectation that we will need it shortly.
Fixed a bit of lint, mostly by reformatting or commenting out currently unused code.
2018-10-15 08:45:34 -07:00
Bruce Martin bf0154ada6 add current front-end dependencies to documentation (#328)
Add current front-end dependencies to document, highlighting minimum required support for routes.
2018-10-14 08:06:51 -07:00
Bruce Martin dcb3f6e901 fix inconsistent definition of layout route; update intro to match state of spec (#324)
Pre-release cleanup

intro updated to match state of spec
fix PUT/POST inconsistency for /layout route
bug/errata request process defined

Fixes #127
2018-10-12 11:44:21 -07:00
Charlotte Weaver 2e9d576890 Correct capitalization in swagger template (#320) 2018-10-11 16:43:26 -07:00
Charlotte Weaver 3229babd14 Add caching back in (#312)
* Add caching back in

Works now against filter instead of dataframe view

* Layout cache and move post->put

* Fix tests
2018-10-11 16:29:46 -07:00
Charlotte Weaver b90292ce4c Bump interactive limits (#319) 2018-10-11 15:56:58 -07:00
Charlotte Weaver 210787cb5d Filter in engine (#307)
* Add empty filter case

* Filtering dataframes moved to engine instead of rest

* minor changes from PR review

* Minor fixes from PR review

Pass {} instead of none if no filter
chain exceptions
typos
2018-10-11 15:23:31 -07:00
Bruce Martin ee0b066eb6 remove obsolete schema from example data (#315) 2018-10-11 13:02:28 -07:00
Bruce Martin ab76c57fdb updated anndata dependency version (#314)
Update Python requirements.txt to include correct anndata version
2018-10-11 13:02:07 -07:00
Bruce Martin b77d7b14b5 bkmartinjr/world tests (#306)
First cut at test for stateManager/world. Test data for REST responses factored out into separate file for shared use by Universe and World tests. Also cleaned up some debugging code.
2018-10-10 18:05:19 -07:00
Bruce Martin aea934bb6d remove dead code and lint (#310)
remove dead code from client/src/frameworks
delint various files
2018-10-10 18:04:43 -07:00
Charlotte Weaver eb3b3fc59a Reorganize order of methods in engine and rest (#305) 2018-10-10 16:44:05 -07:00
Charlotte Weaver dce778f658 Config file is obsolete (#303) 2018-10-09 11:19:19 -07:00
Charlotte Weaver 11ef5ae51b REST Error handling (#299)
* Use HTTPStatus for all responses

More informative than just the code as an int

* Fill out REST error handling

* Test error routes

* Use HTTP Status for tests too

* factor mime type request into function

* Better mimetype errors

* 404 -> 400 error for bad key
2018-10-08 14:17:56 -07:00
Charlotte Weaver 8be0833d23 /layout put-> post (#301) 2018-10-08 13:38:13 -07:00
Bruce Martin a0aae2e266 bkmartinjr/#294 rest sort (#300)
REST API now requires that the server sort all dataframe responses by dimension index (var or obs index), allowing the client (front-end) to assume responses will be sorted. Given that the dataframe usually has a natural ordering, and the server gets to arbitrarily assign indices, this is the most computationally efficient design.

This PR includes changes to the REST API spec, and removing the explicit sort in the front-end code.
2018-10-08 12:57:06 -07:00
Bruce Martin 19f2728a18 Bkmartinjr/state mgr tests (#298)
Jest tests for part of util/stateManager

* reorganize test directories

* lint

* add Jest config to eslint

* add Jest config to eslint

* lint

* fix babel configuration for tests

* start at kvCache tests

* reorganize test directories

* lint

* add Jest config to eslint

* add Jest config to eslint

* lint

* fix babel configuration for tests

* start at kvCache tests

* fix cache flush bug manifest by new test cases

* new kvCache tests

* skeletal universe test

* fix race in test case

* more universe test cases
2018-10-04 17:41:26 -07:00
Philipp A 8674ad1f7f CLI improvements (#221)
* CLI improvements

* Circumvent apparent bug with subparsers.required
2018-10-04 16:41:14 -07:00
Charlotte Weaver 187bbfdcf7 /data/var (#295)
* Upgrade version of scanpy

* /data/var

This works for everything except the case where there is only one gene. Anndata flattens X when there is only one var thus causing the transpose to fail.

* Fix edge case when an axis (obs/var) only contains 1 element
2018-10-03 15:09:46 -07:00
Bruce Martin 467ae94faf more lint (#291)
* disable function-paren-newline warning

* fix misc lint

* lint

* remove noop exponentiation
2018-10-03 09:18:02 -07:00
Charlotte Weaver d09c820c1b Commit package-lock.json (#289) 2018-10-02 13:12:57 -07:00
Bruce Martin 67fc20b69a add Jest tests to travis (#287)
* add Jest tests to travis

* run npm test from client dir

* use correct npm args
2018-10-01 15:25:27 -07:00
Bruce Martin eeec842ad0 Restv2 feature branch merge to master (#284)
Move to new REST v0.2 communication between front and back-end.   This is a first cut implementation which is functional, but will need follow-up enhancements for performance, error checking, etc.    Protocol spec is in docs directory.

* Add filtering via indexing

* Using new filter specs

Indexing working

* Added filtering by annotation value

* factor out common methods

* Documentation

* create enum for axis (obs/var)

* Better description for filter's return

* Add boolean to enumerated types

* Augmented enum for scanpy axis

* Create schema for annotations

Based on datatype within scanpy/anndata
+ tests

* remove obsolete schema parse script

* Update rest api to remove old routes and add schema route

* Separate development requirements

* Warning for unsupported datatypes

* include -r requirements.txt in dev

* Merged downcast warnings

* Fixed bug where names were NaNs

Needed to include the index too when creating the series

* Add config endpoint

* Generate app features from CLI selections

* Move features to driver

* Add tests for schema

* Clearer version wording

* python3 version of super

* version from engine to package level

* move features to driver

* Revise layout function to match the new spec

* GET for layout/obs

* PUT Layout (#211)

* PUT Layout

* Csweaver/annotations (#212)


* Update scanpy engine to support the rest v0.2 annotation requests

* GET endpoint for obs annotations + tests

* Documentation

* Test annotations in scanpy engine

* Description for annotation-keys param

* annotation->annotations

* clarified return for annotations

* Use URL query list for annotations fields

* parse_filter parses v0.2 GET filters (#215)

* parse_filter parses v0.2 GET filters

* Don't allow index filters from query params

* Better variable conversion

* Parse filter improvements

- uses default dict
- renamed filter -> query_filter

* Cleanup Tasks (#216)

* Add test_api back into travis build

* Do custom JSON encoding the correct way

* Run cellxgene server in test setup

* Cleanup new tests too

* Option to bind to all interfaces (#225)

app.run("0.0.0.0") instead of app.run("127.0.0.1") binds to all interfaces.

Note: There are comments on the internet that says that the flask server is not up to the task of production serving.  I don't think that such scalability concerns apply here, but I was able to get cellxgene working with twistd relatively easily, and we could switch to that if there are scalability concerns.

Test plan: browsed to <ip>:5005/api/v0.2/config on a different host.

* Add filtering via indexing

* Using new filter specs

Indexing working

* Added filtering by annotation value

* factor out common methods

* Documentation

* create enum for axis (obs/var)

* Better description for filter's return

* Add boolean to enumerated types

* Augmented enum for scanpy axis

* Create schema for annotations

Based on datatype within scanpy/anndata
+ tests

* remove obsolete schema parse script

* Update rest api to remove old routes and add schema route

* Separate development requirements

* Warning for unsupported datatypes

* include -r requirements.txt in dev

* Merged downcast warnings

* Fixed bug where names were NaNs

Needed to include the index too when creating the series

* Add config endpoint

* Generate app features from CLI selections

* Move features to driver

* Add tests for schema

* Clearer version wording

* python3 version of super

* version from engine to package level

* move features to driver

* Revise layout function to match the new spec

* GET for layout/obs

* PUT Layout (#211)

* PUT Layout

* Csweaver/annotations (#212)


* Update scanpy engine to support the rest v0.2 annotation requests

* GET endpoint for obs annotations + tests

* Documentation

* Test annotations in scanpy engine

* Description for annotation-keys param

* annotation->annotations

* clarified return for annotations

* Use URL query list for annotations fields

* parse_filter parses v0.2 GET filters (#215)

* parse_filter parses v0.2 GET filters

* Don't allow index filters from query params

* Better variable conversion

* Parse filter improvements

- uses default dict
- renamed filter -> query_filter

* Cleanup Tasks (#216)

* Add test_api back into travis build

* Do custom JSON encoding the correct way

* Run cellxgene server in test setup

* Cleanup new tests too

* Option to bind to all interfaces (#225)

app.run("0.0.0.0") instead of app.run("127.0.0.1") binds to all interfaces.

Note: There are comments on the internet that says that the flask server is not up to the task of production serving.  I don't think that such scalability concerns apply here, but I was able to get cellxgene working with twistd relatively easily, and we could switch to that if there are scalability concerns.

Test plan: browsed to <ip>:5005/api/v0.2/config on a different host.

* Fix merge errors

- import warnings was improperly deleted
- scanpy engine tests were totally wrong

* Fix merge error with driver

* PUT /annotations (#235)

* Add query param for annotation name

* fix descriptions, eliminate else clause

* first cut at initial data load on rest 0.2 api

* Annotation var (#248)

* Fix bug strings are always objects in pandas

* Add axis to annotation method

* Add /annotation/var to REST api

* Csweaver/expressiondata (#242)

* Refactor expression method for REST v2

* Add message to QueryStringError

* Fix range filters

* Add GET route for /data

* /data PUT route

* rename expression to data_frame

* clarification of error

* Improve accept type handling

* support all schema types for 0.2 REST API

* remove REST 0.1 code; connect var annotations loading

* config reducer; use config to set data set title; remove obsolete templating code for data set title

* REST 0.2 expression conversion support

* partial port of expression to REST 0.2

*  diffexp (#273)

* Add diffexp method to scanpy

and test

* Minor tweaks to diffexp

Get a minimal working version to unblock FE development

* Fixing things git deleted

* cleanup print statements

* Add index test

* additional, partial REST 0.2 bring up of diffexp

* Ignore unstructured annotations for data (#275)

This is a temp hack, need to figure out how to include data.uns if there is only one gene

* diffexp REST 0.2 port finish

* ignore unstructured annotaitons on all routes except layout

* correctly use varDataCache; maintain state during world rebuild

* correct varDataCache use

* temporarily disable all memoization

* refinements to expression data caching

* clear cell sets upon regraph/reset

* update version of REST to 0.2

* Travis build fixes

- comment out cache import
- fix duplicate test name

* Remove dependency from travis

* clarify semantics of config variables

* move generic action helpers into util
2018-10-01 14:58:46 -07:00
Bruce Martin f0d9d873be Fix jest test suites (#268)
* CRLF to LF

* add babel-jest

* use ES6 style imports

* fix bugs manifest by test suite

* add babel test config
2018-09-27 17:52:17 -07:00
Bruce Martin 2d218fa099 fix bugs in diffexp docs (#274) 2018-09-25 11:26:54 -07:00
Charlotte Weaver 023fd01957 Added "filter" level for var filter to match other filters (#267) 2018-09-24 15:09:34 -07:00
Bruce Martin 7a39642593 Docs (#265)
* initial commit of REST API doc in MD

* clarify PUT/POST; improve footnote formatting

* change anchor name to endnote
2018-09-24 14:15:50 -07:00
bkmartinjr 967ea21d8a delint components/graph 2018-09-21 15:28:06 -04:00
bkmartinjr 4543b002e3 lint fixes 2018-09-20 14:52:17 -04:00
bkmartinjr d9a75fcd10 more linting 2018-09-19 10:30:14 -04:00
bkmartinjr 9ee8781f32 disable react/prop-types 2018-09-19 10:30:14 -04:00
bkmartinjr b2c42b6379 delint 2018-09-19 10:30:14 -04:00
bkmartinjr f8b00af011 move redbox-react to depenedencies 2018-09-19 10:30:14 -04:00
Bruce Martin d4e850d18f #130 redux refactor (#208)
* mocks for redux refactor - for discussion

* more API design on redux refactor

* add new reuqired dependencies for build

* change babel target to use modern browser

* remove dead code

* remove dead code - joy plots

* checkpoint on redux refactoring

* checkpoint on redux refactoring

* fix mistaken rebase conflict resolution

* dead code removal; add name to dataframe backmap

* rename dataframe to universe

* update eslint config to more closely match prettier

* lint

* more eslint updates to match prettier

* additional config to make eslint match prettier

* add expression data to Universe/World

* remove obsolete reducers

* lint fixes

* more eslint cleanup

* lint

* lint

* fix but in countAllOnes when dimensions gt 1

* lint; do not display name metadata field

* lint; colors refactor

* lint; colors refactor

* update comments

* first cut at regraph and reset

* enable object-curly-braces consistent mode

* lint, handle regraph with no selection

* fix expression scatterplot bugs

* fix regression legend display

* add expression data cache

* remove console logging

* reset cell color on regraph/reset

* remove obsolete server URLs

* rename UniverseV01 to Universe_REST_API_v01

* add additional comments on the varDataCache

* merge universe reducer into controls reducer; simplify initialization-related actions

* use spread operator

* fix erroneous comment

* convert universe and world state to plain objects, and functionalize supporting code (remove ES6 classes)

* use spread operator

* lint

* improve variable names

* rename obsCrossfilter to crossfilter and obsDimensionMap to dimensionMap

* rename controls2 to controls
2018-09-17 20:43:39 -07:00
fionagriffin 7b00fea44d Update README.md (#218) 2018-09-14 16:02:22 -07:00
Bruce Martin 18e1b0f2fb Merge pull request #195 from chanzuckerberg/layout-ui-changes
Layout changes
2018-08-20 08:43:02 -07:00
Colin Megill f7abee5156 kill logging 2018-08-19 19:57:43 -04:00
Colin Megill fddf114076 don't render anything until everything loads 2018-08-19 19:57:26 -04:00
Colin Megill d550b5503d graph needs to know about first time rendering 2018-08-19 19:57:11 -04:00
Colin Megill 743eee3d78 maximize space for heatmap 2018-08-19 19:20:57 -04:00
Colin Megill d492e129d0 standardize buttons, move diffexp to lower right 2018-08-19 19:19:22 -04:00
Colin Megill 6665f55075 Merge pull request #193 from chanzuckerberg/#175-d3-globals
Remove d3 global import / add to bundle
2018-08-16 10:58:48 -04:00
Colin Megill 44ef1af863 with direct imports of viridis scale, 2 packages no longer needed 2018-08-16 10:14:25 -04:00
Colin Megill 6fd7f23364 dead code cleanup 2018-08-16 10:13:13 -04:00
Colin Megill 3c9de7be9e add d3 and scale chromatic imports 2018-08-16 10:11:18 -04:00
Colin Megill 3087c173e6 remove import for joyplots 2018-08-16 10:07:26 -04:00
Colin Megill e4cd873b21 better positioning of continuous paintbrushes 2018-08-16 10:07:08 -04:00
Colin Megill 67bf7491c6 add d3 packages via npm 2018-08-16 10:06:46 -04:00
Colin Megill 531e60caa4 remove d3 script tags in index 2018-08-16 10:06:09 -04:00
bkmartinjr f8bf32656c outdated package update; update to babel-preset-env in test config 2018-08-14 13:42:21 -07:00
Bruce Martin a7f728a9e8 Merge pull request #187 from chanzuckerberg/webpack4-replay
Webpack4 port
2018-08-14 13:12:25 -07:00
Charlotte Weaver 1712114c9d Merge pull request #190 from chanzuckerberg/revert-179-csweaver/api-v2-init
Revert "Format loaded dataset"
2018-08-14 10:43:15 -07:00
Charlotte Weaver 39414503bd Revert "Format loaded dataset" 2018-08-14 10:40:21 -07:00
Charlotte Weaver 451c930cb5 Merge pull request #186 from chanzuckerberg/csweaver/gitignore
Cleanup .gitignore
2018-08-14 09:52:43 -07:00
bkmartinjr b2619354c5 remove unused babel preset 2018-08-13 17:07:54 -07:00
bkmartinjr fbf798f4c4 update istanbul devDependency 2018-08-13 17:07:54 -07:00
bkmartinjr de5a6b1d29 remove polyfills 2018-08-13 17:07:53 -07:00
bkmartinjr 831ef283a0 switch to babel-polyfill 2018-08-13 17:07:53 -07:00
bkmartinjr 113cf17183 update package versions; upgrade webpack-dev-middleware options for dev server 2018-08-13 17:07:53 -07:00
bkmartinjr a03f4bcdd9 move Promise to dependencies 2018-08-13 17:07:53 -07:00
bkmartinjr 34561a3b08 further webpack config cleanup 2018-08-13 17:07:53 -07:00
bkmartinjr 4e80ed6c5d clean up babel config - remove deprecated options and extraneous package loads 2018-08-13 17:07:53 -07:00
bkmartinjr 73b6658d29 formatting of package.json 2018-08-13 17:07:53 -07:00
bkmartinjr 2cda3fb68f add jest config 2018-08-13 17:07:47 -07:00
bkmartinjr 2bf8b273d3 continued webpack4 port: update to recent build packages, remove unused packages, production webpack port 2018-08-13 17:07:01 -07:00
bkmartinjr 563b1c34c9 additional webpack4 migration steps: add mode to webpack config; add webpack-cli to package.json 2018-08-13 17:07:01 -07:00
Colin Megill 985cc0de94 update webpack config rules 2018-08-13 17:07:00 -07:00
Colin Megill c57d648f15 update packages in package.json for webpack4 2018-08-13 17:05:14 -07:00
Colin Megill 33ce77b669 reformat css loaders 2018-08-13 17:03:15 -07:00
Charlotte Weaver 9d680cf8d8 Merge pull request #179 from chanzuckerberg/csweaver/api-v2-init
Format loaded dataset
2018-08-13 16:02:07 -07:00
Charlotte Weaver b884487789 Merge branch 'master' into csweaver/api-v2-init 2018-08-13 15:48:06 -07:00
Charlotte Weaver ddaa7016a5 Quote formatting
' => "
2018-08-13 15:00:23 -07:00
Charlotte Weaver a29ba8da4e Limit pytest ignore warning to only one specific warning 2018-08-13 15:00:06 -07:00
Charlotte Weaver 463cdef08b Cleanup .gitignore
remove lines related to old separate repo version
add ignore for virtual environments and jupyter notebooks (I use them to test)
2018-08-13 14:35:41 -07:00
Charlotte Weaver c8c098282d Merge pull request #185 from chanzuckerberg/csweaver/favicon
Csweaver/favicon
2018-08-13 10:56:39 -07:00
Charlotte Weaver 8c88f752ea Merge pull request #182 from chanzuckerberg/csweaver/static
Set static folder to correct location
2018-08-13 10:48:09 -07:00
Charlotte Weaver a4464d107f Add test to make sure static files are served
this required that I break up the base url from the api route.
2018-08-13 10:33:59 -07:00
Charlotte Weaver d44d267bda Initial tests for the rest v2 refactor
testing the annotations and the data format validation
2018-08-10 16:57:41 -07:00
Charlotte Weaver 55fa9b892a Splitting the format validation and mandatory annotations 2018-08-10 16:38:07 -07:00
Bruce Martin 5917dd1fb4 Merge pull request #181 from chanzuckerberg/csweaver/favicon
Serving favicon correctly
2018-08-10 08:28:49 -07:00
Charlotte Weaver 21a1f00a64 Set static folder to correct location 2018-08-09 14:25:11 -07:00
Charlotte Weaver e96653b826 Serving favicon correctly 2018-08-09 14:14:45 -07:00
Bruce Martin d01792f22b Merge pull request #178 from chanzuckerberg/bkmartinjr/crlf-cleanup
clean up CRLF
2018-08-09 11:31:18 -07:00
Charlotte Weaver 58000c1815 Format loaded dataset
- create cell and gene ids
- recast numbers to float32/int32
2018-08-09 11:23:43 -07:00
bkmartinjr 4417e8f3da clean up CRLF 2018-08-09 10:50:54 -07:00
Charlotte Weaver 3961bdc46d Merge pull request #166 from chanzuckerberg/csweaver/clean
Added clean script for cellxgene
2018-08-09 10:22:13 -07:00
Charlotte Weaver 004303300b -f flag for file removal too 2018-08-09 10:09:19 -07:00
Charlotte Weaver ba1e26138f Merge pull request #167 from chanzuckerberg/csweaver/build-anywhere
Allow build-client to run anywhere
2018-08-09 09:45:47 -07:00
Charlotte Weaver d093dc2e3b Allow build-client to run anywhere 2018-08-08 15:17:23 -07:00
Charlotte Weaver 3f0bb2ce1d Added clean script for cellxgene 2018-08-08 15:10:33 -07:00
Bruce Martin 585d51e9cf Merge pull request #164 from chanzuckerberg/bkmartinjr-crossfiltergroups
add groups functionality to crossfilter
2018-08-08 14:33:29 -07:00
Charlotte Weaver 246f4e41f0 Merge pull request #156 from chanzuckerberg/csweaver/infer-metadata
Infer schema if none exists
2018-08-08 11:56:13 -07:00
Charlotte Weaver 67504dcaff Merge pull request #142 from chanzuckerberg/csweaver/cli-subparsers
Csweaver/cli subparsers
2018-08-08 11:52:38 -07:00
Charlotte Weaver af85792cce Don't forget unsigned ints! 2018-08-08 11:20:01 -07:00
Charlotte Weaver f4780a9806 Moved add to parser out of try block. 2018-08-08 11:18:06 -07:00
bkmartinjr 8119962c28 revert the module change reversion 2018-08-08 10:45:48 -07:00
bkmartinjr b2add5f2ce Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-08-08 08:44:02 -07:00
Charlotte Weaver 2a11af2550 Formatting 2018-08-07 17:01:19 -07:00
Charlotte Weaver 2b698cc7d0 Merge remote-tracking branch 'origin/csweaver/cli-subparsers' into csweaver/cli-subparsers 2018-08-07 16:57:13 -07:00
Charlotte Weaver e5c7c0c44a Warn instead if print when scanpy isn't available 2018-08-07 16:57:07 -07:00
Charlotte Weaver 11f88a9eb2 Warn instead if print when scanpy isn't available 2018-08-07 16:49:40 -07:00
Charlotte Weaver bf19bb7dce Formatting 2018-08-07 16:38:25 -07:00
Charlotte Weaver c2b3f331cf Add layout and diffexp calculations to cli options 2018-08-07 16:37:47 -07:00
Charlotte Weaver 499b9551f1 moved scanpy parser to scanpy class 2018-08-07 16:37:02 -07:00
Charlotte Weaver 0969854e9e Merge pull request #162 from chanzuckerberg/csweaver/require
Trimming out secondary dependencies from requirements
2018-08-07 16:14:28 -07:00
Charlotte Weaver 247b89ab0c Update README.md 2018-08-07 15:54:25 -07:00
bkmartinjr 736ad2ca20 Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-08-07 15:46:43 -07:00
Bruce Martin 77f6e918b4 Merge pull request #163 from chanzuckerberg/csweaver/api-base
api host 0.0.0.0 -> 127.0.0.1
2018-08-07 15:46:13 -07:00
Charlotte Weaver ccee3e0a38 api host 0.0.0.0 -> 127.0.0.1 2018-08-07 15:43:13 -07:00
bkmartinjr 7ac8d31a96 Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-08-07 13:52:12 -07:00
Charlotte Weaver c11ae77de4 Trimming out secondary dependencies from requirements 2018-08-07 12:53:48 -07:00
Colin Megill c47f8d6ca5 Merge pull request #161 from chanzuckerberg/#152-security-warnings
remove warnings in npm install, resolve security warnings
2018-08-07 15:35:27 -04:00
Colin Megill 10cc801197 remove warnings in npm install, resolve security warnings 2018-08-07 15:00:28 -04:00
Charlotte Weaver 0db24a6c07 Merge pull request #157 from chanzuckerberg/csweaver/readme-data
Added instructions on how to load your own data in scanpy
2018-08-07 10:19:31 -07:00
Charlotte Weaver 9236f02637 Merge branch 'master' into csweaver/readme-data 2018-08-07 10:18:57 -07:00
bkmartinjr 957c1a9f20 Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-08-07 08:23:11 -07:00
Sidney Bell 12c52a55f7 Very minor changes to elaborate in a few places 2018-08-07 07:42:45 -07:00
Charlotte Weaver 16d7fac88e Added instructions on how to load your own data in scanpy 2018-08-06 17:15:35 -07:00
Charlotte Weaver a2b699b05d Infer schema if none exists 2018-08-06 16:39:18 -07:00
Charlotte Weaver 12b8232327 Merge pull request #155 from chanzuckerberg/csweaver/pip-install
Use pip instead of python setup.py
2018-08-06 16:03:39 -07:00
Charlotte Weaver a22a4e2473 Use pip instead of python setup.py 2018-08-06 14:56:42 -07:00
bkmartinjr 00be4b710e Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-08-06 12:32:12 -07:00
Charlotte Weaver 8401d1dd3d Merge pull request #154 from chanzuckerberg/build-fix
Fix build
2018-08-06 12:23:41 -07:00
Colin Megill 3822d4b005 upgrade react, webpack inline source, html plugin 2018-08-06 15:16:17 -04:00
bkmartinjr 72afb3510c Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-08-06 09:49:00 -07:00
Colin Megill 6bc94a53ce Merge pull request #148 from chanzuckerberg/bkmartinjr/getfix2
fix for _.get typo
2018-08-06 12:29:41 -04:00
Colin Megill 15923d4523 Merge pull request #151 from chanzuckerberg/bkmartinjr/package-cleanup
remove duplicates dependencies
2018-08-06 12:26:55 -04:00
bkmartinjr c69a730e12 remove duplicates dependencies 2018-08-06 08:40:15 -07:00
bkmartinjr ff07bb1ebb fix for _.get typo 2018-08-04 15:14:40 -07:00
bkmartinjr 60238f61c4 Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-08-04 13:06:08 -07:00
bkmartinjr 8f56f0ce6e crossfilters: reverse ES6 module change; add group support for enumerated dimensions 2018-08-04 13:05:50 -07:00
bkmartinjr f839653c00 add Jest tests for new crossfilter group functions 2018-08-04 13:04:09 -07:00
bkmartinjr a39e0c79a6 add Jest configuration for node-based testing 2018-08-04 13:03:43 -07:00
Charlotte Weaver 0c8b86d1c4 Merge pull request #141 from chanzuckerberg/csweaver/datatoapp
Move data to a property of the app object
2018-08-03 13:03:34 -07:00
Colin Megill dfea46eff0 Merge pull request #144 from chanzuckerberg/#41-get-fixes
_.get instead of ternary
2018-08-03 15:22:08 -04:00
Colin Megill 76d5252506 Merge pull request #143 from chanzuckerberg/#53-react-upgrade
React 16
2018-08-03 15:20:08 -04:00
Colin Megill cb6b784dfd final component conversions 2018-08-03 15:18:40 -04:00
Charlotte Weaver e682028b8e Use flask's current_app 2018-08-03 10:54:43 -07:00
Sidney Bell 77ca5c267e Update readme (#137)
* Update readme

* Add demo gif

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md
2018-08-03 10:23:57 -07:00
Charlotte Weaver 8dbb131afd Move data to a property of the app object 2018-08-02 16:18:17 -07:00
Charlotte Weaver 64bfb7416e Merge pull request #121 from chanzuckerberg/csweaver/cli
Csweaver/cli
2018-08-02 15:12:11 -07:00
Colin Megill 7ae1db627c continuous legend conversion 2018-08-02 15:32:26 -04:00
Charlotte Weaver ee5fe8f37b Merge pull request #138 from chanzuckerberg/csweaver/cache
Add simple cache to backend
2018-08-02 11:55:21 -07:00
Charlotte Weaver e11b905836 Cleaning up import statements 2018-08-02 11:34:00 -07:00
Charlotte Weaver 116cef551c Switch to default timeout for cache 2018-08-02 11:28:44 -07:00
bkmartinjr 993057b79c Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-08-01 16:32:56 -07:00
bkmartinjr 5400df25ca finish group() implementation 2018-08-01 16:32:31 -07:00
Charlotte Weaver 489a2dd5db Add flask-caching to requirements 2018-08-01 16:29:38 -07:00
Charlotte Weaver 0a36fac142 10 day cache (down from 1000)
I can expect a scientist to leave this running over the weekend on their laptop and still expect fast results. If someone leaves it running for a few years the data can probably be safely recalculated.
2018-08-01 15:48:56 -07:00
Charlotte Weaver 69fca7ea8b Using flask_caching instead
More recent module, same API
2018-08-01 15:46:04 -07:00
Charlotte Weaver 7642251d44 Add simple cache to backend 2018-08-01 13:32:04 -07:00
Colin Megill 20331313e0 move to componentDidUpdate 2018-07-31 16:50:48 -04:00
Colin Megill ee2b7fc13f stub getDerivedStateFromProps 2018-07-31 11:07:53 -04:00
Colin Megill 78077ad566 pulse loader doesn't work with 16 2018-07-30 14:29:54 -04:00
Colin Megill 0b84e9e804 react 16 2018-07-30 14:29:44 -04:00
Colin Megill b574542a60 _.get fixes 2018-07-23 17:06:32 -04:00
Charlotte Weaver 516ecc2ad3 Merge pull request #123 from chanzuckerberg/csweaver/rm-duplicate-tests
Removed duplicate jest tests
2018-07-20 13:58:00 -07:00
Charlotte Weaver d77accc858 Update travis for CLI 2018-07-20 13:15:17 -07:00
Charlotte Weaver 83e7685f57 Update readme for CLI 2018-07-20 11:47:42 -07:00
Charlotte Weaver de5e0f4ea7 Address PR review issues 2018-07-20 11:43:31 -07:00
Charlotte Weaver 4b2f24cb40 Removed duplicate jest tests 2018-07-20 11:06:59 -07:00
Charlotte Weaver fec055ede3 Merge pull request #122 from chanzuckerberg/csweaver/branding-template
Updated obvious areas to cellxgene branding
2018-07-20 09:21:29 -07:00
Charlotte Weaver fdd9b1d0bd Updated obvious areas to cellxgene branding 2018-07-19 17:37:40 -07:00
Charlotte Weaver 9c72f2c2d5 Updating travis file to match new cli 2018-07-19 17:23:30 -07:00
Charlotte Weaver 2c7a11ab80 Added cli command for run 2018-07-19 17:21:28 -07:00
Charlotte Weaver 45f61d87d7 Initial CLI
added basic cli for running
2018-07-18 21:45:07 -07:00
Charlotte Weaver 71d1cd250c Merge pull request #119 from chanzuckerberg/csweaver/travis
Setup travis integration
2018-07-18 14:14:13 -07:00
Charlotte Weaver 558e8f522a Style for travis build files 2018-07-18 14:07:04 -07:00
Charlotte Weaver 895cfcebe0 Added initial travis config file 2018-07-18 10:21:24 -07:00
Charlotte Weaver cd7c06c822 Merge pull request #120 from chanzuckerberg/csweaver/packaging-improvements
Csweaver/packaging improvements
2018-07-17 16:25:31 -07:00
Charlotte Weaver 8b0d7d5cf6 Clarify install docs for python3 and venvs 2018-07-17 16:03:33 -07:00
Charlotte Weaver 28d341cf1c Fix default path for dataset 2018-07-17 16:03:06 -07:00
Charlotte Weaver 59022d7a9c Merge pull request #117 from chanzuckerberg/csweaver/tests
Csweaver/tests
2018-07-17 13:29:48 -07:00
Charlotte Weaver fdaaaf69a2 use unittest assert methods instead of assert 2018-07-17 11:01:38 -07:00
Charlotte Weaver 6961b64d87 Updated readme for python packaged version 2018-07-16 14:20:50 -07:00
Charlotte Weaver a5423a6911 Adding minor tests to scanpy calculation code 2018-07-16 11:48:53 -07:00
Charlotte Weaver 05b9a6833d Add tests for scanpy engine 2018-07-16 11:48:53 -07:00
Charlotte Weaver b8f501a46c Move to running tests after installing
to avoid the sys.path.insert
2018-07-16 11:48:53 -07:00
Charlotte Weaver b6f253e843 Initial packaging working
pip install works if client files are built and moved manually
2018-07-16 11:48:53 -07:00
Charlotte Weaver 851ddde130 Merge pull request #116 from chanzuckerberg/csweaver/packaging
Csweaver/packaging
2018-07-16 11:44:56 -07:00
Charlotte Weaver 2e1da27a31 Merge branch 'webgl-pause' 2018-07-16 11:11:23 -07:00
Charlotte Weaver d375c48b6a Merge branch 'master' into webgl-pause 2018-07-16 11:10:51 -07:00
Charlotte Weaver 82c4d2948c Merge branch 'csweaver/addbackend' 2018-07-16 10:58:46 -07:00
Charlotte Weaver 5a15f5a915 mergining addbackend 2018-07-16 10:58:33 -07:00
Charlotte Weaver 8aafd7d271 fix eslint-scope vulnerability 2018-07-12 13:05:27 -07:00
Charlotte Weaver 02799cea8e No relative imports "up" 2018-07-12 12:59:34 -07:00
Charlotte Weaver ba9e5f1e41 flake8/lint cleanup 2018-07-12 11:52:24 -07:00
bkmartinjr cc354d7d61 Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-07-12 11:46:31 -07:00
Colin Megill 03ec942723 Merge pull request #115 from chanzuckerberg/eslint-scope
eslint scope pin
2018-07-12 14:37:29 -04:00
Colin Megill dd019d83e5 eslint scope pin 2018-07-12 14:36:57 -04:00
Colin Megill 2721e91788 zoom working 2018-07-11 11:28:23 -04:00
bkmartinjr 2970189397 Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-crossfiltergroups 2018-07-10 13:52:35 -07:00
bkmartinjr 76446ca7d4 start at groups implementation for typedCrossfilter 2018-07-10 13:52:22 -07:00
Charlotte Weaver f466ef6553 Added entry point to run cellxgene from console 2018-07-10 12:56:42 -07:00
Charlotte Weaver a3bf802aec Add example dataset 2018-07-06 16:03:24 -07:00
Charlotte Weaver 071c24d0f4 Add client build script 2018-07-06 15:59:20 -07:00
Charlotte Weaver d937485034 Merge branch 'csweaver/packaging' of github.com:chanzuckerberg/cellxgene into csweaver/packaging 2018-07-06 15:51:08 -07:00
Charlotte Weaver 0639759a90 Initial packaging working
pip install works if client files are built and moved manually
2018-07-06 15:44:22 -07:00
Charlotte Weaver 558950852e Merge remote-tracking branch 'origin/csweaver/addbackend' into csweaver/addbackend 2018-07-06 15:43:37 -07:00
Charlotte Weaver cd1b67fd09 Rename diffexpression 2018-07-06 15:41:08 -07:00
Charlotte Weaver 5b9ddfa1a6 Added documentation to filter 2018-07-06 15:40:21 -07:00
Charlotte Weaver 354eacc3c8 Remove cell ids from engine
Previously I just removed them from the driver
2018-07-06 15:38:42 -07:00
Charlotte Weaver cf12acd47c Added documentation to filter 2018-07-06 10:26:46 -07:00
Charlotte Weaver 6652c07495 Remove cell ids from engine
Previously I just removed them from the driver
2018-07-06 10:26:33 -07:00
Charlotte Weaver e3bf06ca61 Config file no longer necessary
Config is solely from environment variables instead.
2018-07-06 10:13:24 -07:00
Charlotte Weaver 01de23c533 Fixed some faulty requirements
Not tested on latest flask version yet
The latest anndata is causing problems with sorting  numeric indexes. I made a separate issue to actually fix that at a later point.
2018-07-06 10:12:32 -07:00
Colin Megill 4365b24212 remove log 2018-07-05 21:55:30 -04:00
Colin Megill aa6f38e0a3 working! & pretty smooth, with responsive extra click 2018-07-05 21:44:36 -04:00
Charlotte Weaver 451f95b0a4 Initial packaging working
pip install works if client files are built and moved manually
2018-07-05 17:06:51 -07:00
Colin Megill 013c5fb078 remove log 2018-07-05 16:38:50 -04:00
Colin Megill d1895ecb05 Break out draw function 2018-07-05 16:38:43 -04:00
Charlotte Weaver 85506db971 Updating to latest cellxgene client code
Fixes the build error in the old version. 

Reverted the API version since we changed directions from updating the API to refactoring the server structure instead.
2018-07-05 13:37:26 -07:00
Colin Megill e383e52320 replace viewportwidth with 1 2018-07-05 15:53:03 -04:00
Charlotte Weaver 2da62720dd Better documentation for driver/engines
Removed the REST v2.0 documentation in favor of getting v1.0 working with driver/engine model
2018-06-29 17:17:14 -07:00
Charlotte Weaver 56c932a443 Fixed categorical filter not being converted to type, added tests 2018-06-29 17:16:00 -07:00
Charlotte Weaver 40f5b94075 Consistency with quotes
' -> "
2018-06-29 13:14:58 -07:00
Colin Megill 1eeaca41f9 Merge pull request #107 from chanzuckerberg/freeman-lab/brush-zoom-fix
Fix zooming selection
2018-06-28 01:24:34 -04:00
Colin Megill 7a9f0ef29e sourceEvent instead of selection 2018-06-28 01:23:28 -04:00
Colin Megill 91902aa33e Merge pull request #82 from chanzuckerberg/freeman-lab/brush-zoom-fix
fixed brush behavior when zooming + panning
2018-06-28 01:22:39 -04:00
Colin Megill b24b71692c brush on zoom procedural deselect 2018-06-28 01:10:29 -04:00
Colin Megill 64223ff95e return brush independently from container 2018-06-28 01:10:05 -04:00
Charlotte Weaver ba13e32206 Add python's requirements.txt 2018-06-27 13:40:35 -07:00
Charlotte Weaver a60b3ea296 Put cellxgene readme back in
Will update readme to make server side work once build/packaging in.
2018-06-26 11:51:58 -07:00
Charlotte Weaver dd5fa57259 renaming backend, cellxgene to server, client respectively 2018-06-26 11:41:32 -07:00
Charlotte Weaver 59dcfe2bc4 Correct tabs to spaces 2018-06-26 11:22:34 -07:00
Charlotte Weaver c7f9dc0bb9 Remove redundant app start 2018-06-26 11:21:35 -07:00
Charlotte Weaver 67f2e19f39 Added tests for all endpoints 2018-06-25 15:43:18 -07:00
Charlotte Weaver ca16447115 Style guide cleanup 2018-06-22 17:43:10 -07:00
Charlotte Weaver 15ce4cc338 Add back in /expression and /diffexp endpoints 2018-06-22 17:18:22 -07:00
Charlotte Weaver ee8bb510ff /cells is working 2018-06-22 11:52:05 -07:00
Charlotte Weaver 9b4bcb7f7b Original /initialize working 2018-06-22 11:00:22 -07:00
Charlotte Weaver 1621d20bc4 Minimum working iterator version
Checkpointing. This is a minimum working version using the iterator. I basically hacked consuming the iterator since I want to switch directions, but this is a good place to come back to.
2018-06-22 10:01:52 -07:00
Colin Megill f96a03e58c Merge pull request #84 from chanzuckerberg/color-by-continuous
reinstitute color by continuous
2018-06-13 12:24:57 -07:00
Colin Megill f0e008eb66 reinstitute color by continuous 2018-06-13 12:09:53 -07:00
freeman-lab a4e75fceba incorporate distance inverse term 2018-06-09 14:38:09 -07:00
Charlotte Weaver fabd3ca2ff Implement driver class for scanpy 2018-06-07 15:22:32 -07:00
Colin Megill 2fb086b5ad Merge pull request #81 from chanzuckerberg/checkbox
checkbox intermediate state
2018-06-07 13:01:26 -04:00
Colin Megill d1d3353130 checkbox intermediate state 2018-06-07 13:00:03 -04:00
Colin Megill e6c912f6ad Merge pull request #80 from chanzuckerberg/#62-persist-scatter
render scatterplot on mount, handle switch tabs case
2018-06-06 17:43:47 -04:00
Charlotte Weaver 9cf300caeb Fleshed out documentation and params for methods
Given feedback from designing rest v2.0
2018-06-05 16:05:04 -07:00
Colin Megill 7ecd61ce0f render scatterplot on mount, handle switch tabs case 2018-06-05 12:04:26 -04:00
Colin Megill a64484a1dd Merge pull request #79 from chanzuckerberg/continuous-scale-rerender
don't rerender continuous scale on responsive
2018-06-04 23:41:38 -04:00
Colin Megill ebb1bb93cf don't rerender continuous scale on responsive 2018-06-04 23:40:22 -04:00
Charlotte Weaver e84793ede9 Add backend to cellxgene repo 2018-06-01 11:16:23 -07:00
Colin Megill c60904a145 Merge pull request #74 from chanzuckerberg/bkmartinjr-addjest
Jest tests for TypedCrossfilter
2018-05-31 19:13:22 -04:00
bkmartinjr a0d5f1bf86 Merge branch 'master' of https://github.com/chanzuckerberg/cellxgene into bkmartinjr-addjest 2018-05-30 10:36:21 -07:00
bkmartinjr 9e8d48d630 more Jest tests for TypedCrossfilter 2018-05-30 10:35:49 -07:00
bkmartinjr 0b50f02d55 fix id() bug in ScalarDimension 2018-05-30 09:23:25 -07:00
bkmartinjr a0c6e3102b fix bug in fillBySelection 2018-05-30 09:03:03 -07:00
Colin Megill 37df1d7a67 Merge pull request #73 from chanzuckerberg/continuousLegend
continuous legend
2018-05-30 11:22:47 -04:00
Colin Megill 2b70d165be Merge pull request #72 from chanzuckerberg/bkmartinjr-regraph
fix regraph metadata bug
2018-05-30 11:22:07 -04:00
bkmartinjr bf6ebd5741 reset all cells on graph reset 2018-05-29 21:40:51 -07:00
bkmartinjr 9c2870e995 fix logic inversion on regraph 2018-05-29 21:38:47 -07:00
Colin Megill 1565f47f6e continuous legend 2018-05-30 00:21:11 -04:00
bkmartinjr 92dfc5f2ee add Jest tests 2018-05-29 21:16:48 -07:00
bkmartinjr eaa4e6c7a4 fix bug in selectOne and deselectOne 2018-05-29 20:34:59 -07:00
Colin Megill 67e65e7535 Merge pull request #71 from chanzuckerberg/bkmartinjr-staterefactor
introduce new selection state machinery
2018-05-29 15:04:21 -04:00
bkmartinjr 20756764f9 add comments 2018-05-29 11:58:57 -07:00
bkmartinjr 2f23e8675f fix typo generating exception 2018-05-29 10:01:33 -07:00
bkmartinjr 089d04ea9f move schema sniffer to util 2018-05-28 16:52:32 -07:00
bkmartinjr cfd13cad41 refactor regraph functionality to use crossfilter; add reset graph 2018-05-27 11:49:50 -07:00
bkmartinjr 2c79e98809 reorganize TypedCrossfilter code 2018-05-27 08:02:47 -07:00
bkmartinjr 7874e6a68b better caching of graph regl data 2018-05-25 17:27:50 -07:00
bkmartinjr 007e1d3a66 remove detritus 2018-05-23 21:01:28 -07:00
bkmartinjr cc661f31e1 first cut at high performance crossfilter 2018-05-23 20:59:13 -07:00
248 changed files with 34952 additions and 4747 deletions
+15
View File
@@ -0,0 +1,15 @@
[bumpversion]
current_version = 0.9.2
[bumpversion:file:setup.py]
search = version="{current_version}"
replace = version="{new_version}"
[bumpversion:file:server/cli/cli.py]
search = version="{current_version}"
replace = version="{new_version}"
[bumpversion:file:client/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
+5
View File
@@ -0,0 +1,5 @@
bin
client
dist
docs
server
+31 -3
View File
@@ -1,16 +1,44 @@
# dependencies # dependencies
node_modules node_modules/
# coverage # coverage
coverage coverage
.nyc_output .nyc_output
.pytest_cache/
# production # production
build build/
dist/
*.egg-info
# Environments
venv/
cellxgene/
# client build
server/app/web/static/css/
server/app/web/static/img/
server/app/web/static/media/
server/app/web/static/js/
server/app/web/templates/index\.html
# Jupyter Notebook
.ipynb_checkpoints
*.ipynb
# cefpython
error.log
# misc # misc
.DS_Store .DS_Store
npm-debug.log npm-debug.log
.vscode .vscode
*.idea*
__pycache__
*.DS_Store*
data data
# Jekyll
docs/_site/
docs/Gemfile.lock
+32
View File
@@ -0,0 +1,32 @@
language: python
dist: xenial
sudo: required
node_js:
- 8
cache:
- pip
- npm
install:
- set -eo pipefail
- pip install flake8
- make build
- make install
- pip install -r server/requirements-dev.txt
jobs:
include:
- name: "Branch Tests 3.7"
python: "3.7"
script: ./travis-build.sh
- name: "Branch Tests 3.6"
python: "3.6"
script: ./travis-build.sh
- name: "Docker Build"
install: skip
python: "3.6"
script: docker build .
- name: "Smoke Tests"
python: "3.6"
if: branch = master AND type = cron
script:
- npm run --prefix client/ smoke-test
+10
View File
@@ -0,0 +1,10 @@
FROM ubuntu:bionic
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
RUN apt-get update && \
apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev && \
pip3 install cellxgene
ENTRYPOINT ["cellxgene"]
+4
View File
@@ -0,0 +1,4 @@
recursive-include server/app/web/templates *
recursive-include server/app/web/static *
include server/requirements.txt
+110 -5
View File
@@ -1,9 +1,114 @@
# cellxgene # cellxgene
A React + Redux web application for exploring large scale single cell RNA sequence data. > an interactive explorer for single-cell transcriptomics data
##### Quickstart: _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.
* `npm install` <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">
* `npm start`
* `localhost:3000` - 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 contribute? See our [contributors guide](#Contributing)
## quick start
To install _cellxgene_ you need Python 3.6+. We recommend [installing _cellxgene_ into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/faq.html#how-do-i-create-a-python-36-environment-for-cellxgene)
Install the package.
``` bash
pip install cellxgene
```
Download an example [anndata](https://anndata.readthedocs.io/en/latest/) file
``` bash
curl -o pbmc3k.h5ad https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/example-dataset/pbmc3k.h5ad
```
Launch _cellxgene_
``` bash
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.
## 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!
## where we are going
Our goal is to enable teams of computational and experimental
biologists to collaboratively gain insight into their single-cell RNA-seq data.
There are 4 key features we plan to implement in the near term.
- Click install and launch
- Manual annotation workflows
- Toggle embeddings
- Gene information
For more detail on these features and where we are going, see [our roadmap](ROADMAP.md).
## contributing
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!
### 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.
## inspiration
We've been heavily inspired by several other related single-cell visualization projects, including the [UCSC Cell Browswer](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [Gene Pattern](http://genepattern-notebook.org/), and many others. We hope to explore collaborations where useful as this community works together on improving interactive visualization for single-cell data.
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
We have been working closely with the [`scanpy`](https://github.com/theislab/scanpy) team to integrate with their awesome analysis tools. Special thanks to Alex Wolf, Fabian Theis, and the rest of the team for their help during development and for providing an example dataset.
We are eager to explore integrations with other computational backends such as [`Seurat`](https://github.com/satijalab/seurat) or [`Bioconductor`](https://github.com/Bioconductor)
## core team
- Colin Megill, frontend & product design
- Charlotte Weaver, software engineer
- Bruce Martin, software engineer
- Sidney Bell, computational biologist
- Justin Kiggins, product manager
## reuse
This project was started with the sole goal of empowering the scientific community to explore and understand their data. As such, we encourage other scientific tool builders in academia or industry to adopt the patterns, tools, and code from this project, and reach out to us with ideas or questions. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
+54
View File
@@ -0,0 +1,54 @@
# cellxgene roadmap
We are very exited for _cellxgene_ to become a valuable tool in collaborations
between computational biologists and experimental biologists working on
single-cell transcriptomics data. _cellxgene_ is in active development, and we
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.
*Last updated: April 11, 2019*
## what we are building now
In the near term, our goal is to enable teams of computational and experimental
biologists to collaboratively explore and annotate their single-cell RNA-seq data.
There are 4 key features we plan to implement in the near term.
- Click install and launch
- Manual annotation workflows
- Toggle embeddings
- Gene information
### simple install and launch
The command line interface for installing and launching cellxgene is a barrier
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
The exploratory visualization that cellxgene offers is critical for manual
annotation workflows, especially in collaborative environments. We plan to
support manually annotate cells with labels (i.e., cell type or QC flags) for
downstream analysis. See [Issue #524](https://github.com/chanzuckerberg/cellxgene/issues/524)
for more details.
### toggle embeddings
While a single dataset may have multiple embeddings calculated (tSNE, umap, in
situ coordinates, trajectories, etc), cellxgene currently requires the user to select the
embedding to use in the main layout at launch. We plan to support letting users
toggle between any embedding present in a file from the cellxgene interface.
See [Issue #594](https://github.com/chanzuckerberg/cellxgene/issues/594) for 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.
+6
View File
@@ -0,0 +1,6 @@
export const jest_env = process.env.JEST_ENV || "dev";
export const appPort = process.env.JEST_CXG_PORT || 3000;
export const appUrlBase = `http://localhost:${appPort}`;
export const DEV = jest_env === "dev";
export const DEBUG = jest_env === "debug";
export const DATASET = "pbmc3k";
+113
View File
@@ -0,0 +1,113 @@
export const datasets = {
pbmc3k: {
title: "cellxgene: pbmc3k",
dataframe: {
nObs: "2638",
nVar: "1838",
type: "float32"
},
categorical: {
louvain: {
"B cells": "342",
"CD14+ Monocytes": "480",
"CD4 T cells": "1144",
"CD8 T cells": "316",
"Dendritic cells": "37",
"FCGR3A+ Monocytes": "150",
Megakaryocytes: "15",
"NK cells": "154"
}
},
continuous: {
n_genes: "int32",
percent_mito: "float32",
n_counts: "float32"
},
cellsets: {
lasso: [
{
"coordinates-as-percent": { x1: 0.25, y1: 0.25, x2: 0.35, y2: 0.35 },
count: "26"
}
],
categorical: [
{
metadata: "louvain",
values: ["B cells", "Megakaryocytes"],
count: "357"
}
],
continuous: [
{
metadata: "n_genes",
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
count: "1537"
}
]
},
diffexp: {
cellset1: [
{ kind: "categorical", metadata: "louvain", values: ["B cells"] }
],
cellset2: [
{
kind: "categorical",
metadata: "louvain",
values: ["CD4 T cells", "NK cells"]
}
],
"gene-results": [
"HLA-DRB1",
"HLA-DPB1",
"CD79A",
"HLA-DPA1",
"HLA-DQA1",
"CD79B",
"HLA-DQB1",
"MS4A1",
"IL32",
"CD37"
]
},
genes: {
bulkadd: ["S100A8", "FCGR3A", "LGALS2", "GSTP1"],
search: "ACD"
},
subset: {
cellset1: [
{
kind: "categorical",
metadata: "louvain",
values: ["B cells", "Megakaryocytes"]
}
],
count: "357",
categorical: {
louvain: {
"B cells": "342",
Megakaryocytes: "15"
}
},
lasso: {
"coordinates-as-percent": { x1: 0.45, y1: 0.45, x2: 0.5, y2: 0.5 },
count: "67"
}
},
scatter: {
genes: { x: "S100A8", y: "FCGR3A" }
},
pan: {
"coordinates-as-percent": { x1: 0.75, y1: 0.75, x2: 0.35, y2: 0.35 }
},
features: {
panzoom: {
lasso: {
"coordinates-as-percent": { x1: 0.3, y1: 0.3, x2: 0.5, y2: 0.5 },
count: "24"
}
}
}
}
};
+308
View File
@@ -0,0 +1,308 @@
/*
Smoke test suite that will be run in Travis CI
Tests included in this file are expected to be relatively stable and test core features
*/
import puppeteer from "puppeteer";
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
import { datasets } from "./data";
let browser, page, utils, cxgActions, spy;
const browserViewport = { width: 1280, height: 960 };
let data = datasets[DATASET];
if (DEBUG) jest.setTimeout(100000);
if (DEV) jest.setTimeout(10000);
beforeAll(async () => {
const browserParams = DEV
? { headless: false, slowMo: 5 }
: DEBUG
? { headless: false, slowMo: 100, devtools: true }
: {};
browser = await puppeteer.launch(browserParams);
page = await browser.newPage();
await page.setViewport(browserViewport);
if (DEV || DEBUG) {
page.on("console", async msg => {
// If there is a console.error but an error is not thrown, this will ensure the test fails
if (msg.type() === "error") {
const errorMsgText = await Promise.all(
// TODO can we do this without internal properties?
msg.args().map(arg => arg._remoteObject.description)
);
throw new Error(`Console error: ${errorMsgText}`);
}
console.log(`PAGE LOG: ${msg.text()}`);
});
}
page.on("pageerror", err => {
throw new Error(`Console error: ${err}`);
});
utils = puppeteerUtils(page);
cxgActions = cellxgeneActions(page);
});
beforeEach(async () => {
await page.goto(appUrlBase);
});
afterAll(() => {
if (!DEBUG) {
browser.close();
}
});
describe("did launch", async () => {
test("page launched", async () => {
let el = await utils.getOneElementInnerHTML("[data-testid='header']");
expect(el).toBe(data.title);
});
});
describe("metadata loads", async () => {
test("categories and values from dataset appear", async () => {
for (const label in data.categorical) {
await utils.waitByID(`category-${label}`);
const categoryName = await utils.getOneElementInnerText(
`[data-testid="category-${label}"]`
);
expect(categoryName).toMatch(label);
await utils.clickOn(`category-expand-${label}`);
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.categorical[label])
);
}
});
test("continuous data appears", async () => {
for (const label in data.continuous) {
await utils.waitByID(`histogram-${label}`);
}
});
});
describe("cell selection", async () => {
test("selects all cells cellset 1", async () => {
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.dataframe.nObs);
});
test("selects all cells cellset 2", async () => {
const cellCount = await cxgActions.cellSet(2);
expect(cellCount).toBe(data.dataframe.nObs);
});
test("selects cells via lasso", async () => {
for (const cellset of data.cellsets.lasso) {
const cellset1 = await cxgActions.calcDragCoordinates(
"layout-graph",
cellset["coordinates-as-percent"]
);
await cxgActions.drag("layout-graph", cellset1.start, cellset1.end, true);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(cellset.count);
}
});
test("selects cells via categorical", async () => {
for (const cellset of data.cellsets.categorical) {
await utils.clickOn(`category-expand-${cellset.metadata}`);
await utils.clickOn(`category-select-${cellset.metadata}`);
for (const val of cellset.values) {
await utils.clickOn(
`categorical-value-select-${cellset.metadata}-${val}`
);
}
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(cellset.count);
}
});
test("selects cells via continuous", async () => {
for (const cellset of data.cellsets.continuous) {
const histId = `histogram-${cellset.metadata}-plot-brush`;
const coords = await cxgActions.calcDragCoordinates(
histId,
cellset["coordinates-as-percent"]
);
await cxgActions.drag(histId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(cellset.count);
}
});
});
describe("gene entry", async () => {
test("search for single gene", async () => {
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
await utils.typeInto("gene-search", data.genes.search);
await page.keyboard.press("Enter");
await page.waitForSelector(
`[data-testid='histogram-${data.genes.search}']`
);
});
test("bulk add genes", async () => {
await cxgActions.reset();
const testGenes = data.genes.bulkadd;
await utils.clickOn("section-bulk-add");
await utils.typeInto("input-bulk-add", testGenes.join(","));
await page.keyboard.press("Enter");
const userGeneHist = await cxgActions.getAllHistograms(
"histogram-user-gene"
);
expect(userGeneHist).toEqual(expect.arrayContaining(testGenes));
});
});
describe("diffexp", async () => {
test("selects cells, saves them and performs diffexp", async () => {
for (const select of data.diffexp.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await cxgActions.cellSet(1);
for (const select of data.diffexp.cellset2) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await cxgActions.cellSet(2);
await utils.clickOn("diffexp-button");
const diffExpHists = await cxgActions.getAllHistograms("histogram-diffexp");
expect(diffExpHists).toEqual(
expect.arrayContaining(data.diffexp["gene-results"])
);
});
});
//
describe("subset/reset", async () => {
test("subset - cell count matches", async () => {
for (const select of data.subset.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await utils.clickOn("subset-button");
for (const label in data.subset.categorical) {
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.subset.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.subset.categorical[label])
);
}
});
test("reset after subset", async () => {
for (const select of data.subset.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await utils.clickOn("subset-button");
for (const label in data.subset.categorical) {
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.subset.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.subset.categorical[label])
);
}
await cxgActions.reset();
for (const label in data.categorical) {
await utils.waitByID(`category-${label}`);
const categoryName = await utils.getOneElementInnerText(
`[data-testid="category-${label}"]`
);
expect(categoryName).toMatch(label);
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.categorical[label])
);
}
});
test("lasso after subset", async () => {
for (const select of data.subset.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
}
}
await utils.clickOn("subset-button");
const lassoSelection = await cxgActions.calcDragCoordinates(
"layout-graph",
data.subset.lasso["coordinates-as-percent"]
);
await cxgActions.drag(
"layout-graph",
lassoSelection.start,
lassoSelection.end,
true
);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.subset.lasso.count);
});
});
describe("scatter plot", async () => {
test("scatter plot appears", async () => {
await cxgActions.reset();
const testGenes = data.scatter.genes;
await utils.clickOn("section-bulk-add");
await utils.typeInto("input-bulk-add", Object.values(testGenes).join(","));
await page.keyboard.press("Enter");
await utils.clickOn(`plot-x-${data.scatter.genes.x}`);
await utils.clickOn(`plot-y-${data.scatter.genes.y}`);
await utils.waitByID("scatterplot");
});
});
// interact with UI elements just that they do not break
describe("ui elements don't error", async () => {
test("color by", async () => {
for (const label in data.categorical) {
await utils.clickOn(`colorby-${label}`);
}
for (const label in data.continuous) {
await utils.clickOn(`colorby-${label}`);
}
});
test("color by for gene", async () => {
await utils.typeInto("gene-search", data.genes.search);
await page.keyboard.press("Enter");
await page.waitForSelector(
`[data-testid='histogram-${data.genes.search}']`
);
await utils.clickOn(`colorby-${data.genes.search}`);
});
test("pan and zoom", async () => {
await utils.clickOn("mode-pan-zoom");
const panCoords = await cxgActions.calcDragCoordinates(
"layout-graph",
data.pan["coordinates-as-percent"]
);
await cxgActions.drag(
"layout-graph",
panCoords.start,
panCoords.end,
false
);
await page.evaluate(`window.scrollBy(0, 1000);`);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"preset": "jest-puppeteer",
"testMatch": [
"**/__tests__/**/?(*.)(spec|test).js?(x)"
],
"testURL": "http://localhost/",
"setupFiles": [
"../setupMissingGlobals.js"
]
}
+118
View File
@@ -0,0 +1,118 @@
/*
NOT run in Travis CI
UX tests using puppeteer to be run locally.
To run locally, ensure you are running the client is running on port 3000.
Then run jest --verbose false --config __tests__/e2e/e2eJestConfig.json feature.
*/
import puppeteer from "puppeteer";
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
import { datasets } from "./data";
let browser, page, utils, cxgActions, spy;
const browserViewport = { width: 1280, height: 960 };
let data = datasets[DATASET].features;
if (DEBUG) jest.setTimeout(100000);
if (DEV) jest.setTimeout(10000);
beforeAll(async () => {
const browserParams = DEV
? { headless: false, slowMo: 5 }
: DEBUG
? { headless: false, slowMo: 100, devtools: true }
: {};
browser = await puppeteer.launch(browserParams);
page = await browser.newPage();
await page.setViewport(browserViewport);
if (DEV || DEBUG) {
page.on("console", msg => console.log(`PAGE LOG: ${msg.text()}`));
}
page.on("pageerror", err => {
throw new Error(`Console error: ${err}`);
});
utils = puppeteerUtils(page);
cxgActions = cellxgeneActions(page);
});
beforeEach(async () => {
await page.goto(appUrlBase);
});
afterAll(() => {
if (!DEBUG) {
browser.close();
}
});
describe("zoom interaction", async () => {
// Skip this test since UI is to hide lasso path when switching modes
test.skip("lasso visible after switching modes to pan/zoom", async () => {
const lassoSelection = await cxgActions.calcDragCoordinates(
"layout-graph",
data.panzoom.lasso["coordinates-as-percent"]
);
await cxgActions.drag(
"layout-graph",
lassoSelection.start,
lassoSelection.end,
true
);
await utils.waitByID("lasso-element", { visible: true });
await utils.clickOn("mode-pan-zoom");
await utils.waitByID("lasso-element", { visible: true });
});
test("pan zoom mode resets lasso selection", async () => {
const lassoSelection = await cxgActions.calcDragCoordinates(
"layout-graph",
data.panzoom.lasso["coordinates-as-percent"]
);
await cxgActions.drag(
"layout-graph",
lassoSelection.start,
lassoSelection.end,
true
);
await utils.waitByID("lasso-element", { visible: true });
const initialCount = await cxgActions.cellSet(1);
expect(initialCount).toBe(data.panzoom.lasso.count);
await utils.clickOn("mode-pan-zoom");
await utils.clickOn("mode-lasso");
const modeSwitchCount = await cxgActions.cellSet(1);
expect(modeSwitchCount).toBe(initialCount);
});
test("lasso moves after pan", async () => {
const lassoSelection = await cxgActions.calcDragCoordinates(
"layout-graph",
data.panzoom.lasso["coordinates-as-percent"]
);
await cxgActions.drag(
"layout-graph",
lassoSelection.start,
lassoSelection.end,
true
);
await utils.waitByID("lasso-element", { visible: true });
const initialCount = await cxgActions.cellSet(1);
expect(initialCount).toBe(data.panzoom.lasso.count);
await utils.clickOn("mode-pan-zoom");
const panCoords = await cxgActions.calcDragCoordinates(
"layout-graph",
data.panzoom.lasso["coordinates-as-percent"]
);
await cxgActions.drag(
"layout-graph",
panCoords.start,
panCoords.end,
false
);
await utils.clickOn("mode-lasso");
const panCount = await cxgActions.cellSet(2);
expect(panCount).toBe(initialCount);
});
});
+165
View File
@@ -0,0 +1,165 @@
export const puppeteerUtils = puppeteerPage => ({
async waitByID(testid, props = {}) {
return await puppeteerPage.waitForSelector(
`[data-testid='${testid}']`,
props
);
},
async waitByClass(testclass, props = {}) {
return await puppeteerPage.waitForSelector(
`[data-testclass='${testclass}']`,
props
);
},
async typeInto(testid, text) {
// only works for text without special characters
await this.waitByID(testid);
// type ahead can be annoying if you don't pause before you type
await puppeteerPage.click(`[data-testid='${testid}']`);
await puppeteerPage.waitFor(200);
await puppeteerPage.type(`[data-testid='${testid}']`, text);
},
async clickOn(testid) {
await this.waitByID(testid);
await puppeteerPage.click(`[data-testid='${testid}']`);
await puppeteerPage.waitFor(50);
},
async getOneElementInnerHTML(selector) {
let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
return text;
},
async getOneElementInnerText(selector) {
let text = await puppeteerPage.$eval(selector, el => el.innerText);
return text;
}
});
export const cellxgeneActions = puppeteerPage => ({
async drag(testid, start, end, lasso = false) {
const layout = await puppeteerUtils(puppeteerPage).waitByID(testid);
const elBox = await layout.boxModel();
const x1 = elBox.content[0].x + start.x;
const x2 = elBox.content[0].x + end.x;
const y1 = elBox.content[0].y + start.y;
const y2 = elBox.content[0].y + end.y;
await puppeteerPage.mouse.move(x1, y1);
await puppeteerPage.mouse.down();
if (lasso) {
await puppeteerPage.mouse.move(x2, y1);
await puppeteerPage.mouse.move(x2, y2);
await puppeteerPage.mouse.move(x1, y2);
await puppeteerPage.mouse.move(x1, y1);
} else {
await puppeteerPage.mouse.move(x2, y2);
}
await puppeteerPage.mouse.up();
},
async getAllHistograms(testclass) {
await puppeteerUtils(puppeteerPage).waitByClass(testclass);
const histograms = await puppeteerPage.$$eval(
`[data-testclass=${testclass}]`,
els => {
return els.map(el => {
return el.dataset.testid.substring(
"histogram_".length,
el.dataset.testid.length
);
});
}
);
return histograms;
},
async getAllCategoriesAndCounts(category) {
await puppeteerUtils(puppeteerPage).waitByClass("categorical-row");
const categories = await puppeteerPage.$$eval(
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
els => {
let result = {};
els.forEach(el => {
const cat = el.querySelector("[data-testclass='categorical-value']")
.innerText;
const count = el.querySelector(
"[data-testclass='categorical-value-count']"
).innerText;
result[cat] = count;
});
return result;
}
);
return categories;
},
async cellSet(num) {
await puppeteerUtils(puppeteerPage).clickOn(`cellset-button-${num}`);
return await puppeteerUtils(puppeteerPage).getOneElementInnerText(
`[data-testid='cellset-count-${num}']`
);
},
async resetCategory(category) {
const checkboxId = `category-select-${category}`;
await puppeteerUtils(puppeteerPage).waitByID(checkboxId);
const checkedPseudoclass = await puppeteerPage.$eval(
`[data-testid='${checkboxId}']`,
el => {
return el.matches(":checked");
}
);
if (!checkedPseudoclass) {
await puppeteerUtils(puppeteerPage).clickOn(checkboxId);
}
try {
const categoryRow = await puppeteerUtils(puppeteerPage).waitByID(
`category-expand-${category}`
);
const isExpanded = await categoryRow.$(
"[data-testclass='category-expand-is-expanded']"
);
if (isExpanded) {
await puppeteerUtils(puppeteerPage).clickOn(
`category-expand-${category}`
);
}
} catch {}
},
async calcDragCoordinates(testid, coordinateAsPercent) {
const el = await puppeteerUtils(puppeteerPage).waitByID(testid);
const size = await el.boxModel();
const coords = {
start: {
x: Math.floor(size.width * coordinateAsPercent.x1),
y: Math.floor(size.height * coordinateAsPercent.y1)
},
end: {
x: Math.floor(size.width * coordinateAsPercent.x2),
y: Math.floor(size.height * coordinateAsPercent.y2)
}
};
return coords;
},
async selectCategory(category, values, reset = true) {
if (reset) await this.resetCategory(category);
await puppeteerUtils(puppeteerPage).clickOn(`category-expand-${category}`);
await puppeteerUtils(puppeteerPage).clickOn(`category-select-${category}`);
for (const val of values) {
await puppeteerUtils(puppeteerPage).clickOn(
`categorical-value-select-${category}-${val}`
);
}
},
async reset() {
await puppeteerUtils(puppeteerPage).clickOn("reset");
// loading state never actually happens, reset is too fast
await page.waitFor(200);
}
});
+48
View File
@@ -0,0 +1,48 @@
import cascadeReducers from "../../src/reducers/cascade";
describe("create", () => {
test("from Array", () => {
expect(cascadeReducers([["foo", () => 0]])).toBeInstanceOf(Function);
});
test("from Map", () => {
expect(cascadeReducers(new Map([["foo", () => 0]]))).toBeInstanceOf(
Function
);
});
});
describe("cascade", () => {
test("expected arguments provided & cascade ordering", () => {
const topLevelState = {};
const topLevelAction = { type: "test" };
const reducer = cascadeReducers([
[
"foo",
(currentState, action, nextSharedState, prevSharedState) => {
expect(currentState).toBeUndefined();
expect(action).toEqual(topLevelAction);
expect(nextSharedState).toStrictEqual({});
expect(prevSharedState).toBe(topLevelState);
return 0;
}
],
[
"bar",
(currentState, action, nextSharedState, prevSharedState) => {
expect(currentState).toBeUndefined();
expect(action).toEqual(topLevelAction);
expect(nextSharedState).toStrictEqual({ foo: 0 });
expect(prevSharedState).toBe(topLevelState);
return 99;
}
]
]);
const nextState = reducer(topLevelState, topLevelAction);
expect(nextState).toStrictEqual({ foo: 0, bar: 99 });
expect(topLevelState).toStrictEqual({});
expect(topLevelAction).toStrictEqual({ type: "test" });
});
});
@@ -0,0 +1,87 @@
import undoable from "../../src/reducers/undoable";
describe("create", () => {
test("no keys", () => {
expect(() => undoable(() => {})).toThrow();
expect(() => undoable(() => {}, null)).toThrow();
expect(() => undoable(() => {}, [])).toThrow();
expect(() => undoable(() => {}, [], {})).toThrow();
});
test("simple", () => {
expect(undoable(() => {}, ["foo"])).toBeInstanceOf(Function);
expect(undoable(() => {}, ["foo"], {})).toBeInstanceOf(Function);
});
test("handles undefined initial state", () => {
expect(
undoable(() => {}, ["a"])(undefined, { type: "test" })
).toMatchObject({});
});
});
describe("undo", () => {
test("expected state modifications", () => {
const initialState = { a: 0, b: 1000 };
const reducer = state => {
return { a: state.a + 1, b: state.b + 1 };
};
const undoableReducer = undoable(reducer, ["a"]);
const s1 = undoableReducer(initialState, { type: "test" });
expect(s1).toMatchObject({ a: 1, b: 1001 });
// test that only specified keys are undone
const s2 = undoableReducer(s1, { type: "@@undoable/undo" });
expect(s2).toMatchObject({ a: 0, b: 1001 });
// test backstop when no more history
const s3 = undoableReducer(s2, { type: "@@undoable/undo" });
expect(s3).toMatchObject({ a: 0, b: 1001 });
});
});
describe("redo", () => {
const initialState = { a: 0, b: 1000 };
const reducer = state => {
return { a: state.a + 1, b: state.b + 1 };
};
let UR;
beforeEach(() => {
UR = undoable(reducer, ["a"]);
});
test("expected state modifications", () => {
const s1 = UR(initialState, { type: "test" });
expect(s1).toMatchObject({ a: 1, b: 1001 });
// verify undo->redo reverts state.
const s2 = UR(UR(s1, { type: "@@undoable/undo" }), {
type: "@@undoable/redo"
});
expect(s2).toMatchObject({ a: 1, b: 1001 });
// verify backstop when no redo future
const s3 = UR(s2, { type: "@@undoable/redo" });
expect(s3).toMatchObject({ a: 1, b: 1001 });
});
test("history cleared", () => {
// verify future cleared upon a normal state transition
const s1 = UR(initialState, { type: "test" });
expect(s1).toMatchObject({ a: 1, b: 1001 });
const s2 = UR(s1, { type: "@@undoable/undo" });
expect(s2).toMatchObject({ a: 0, b: 1001 });
const s3 = UR(s2, { type: "test" });
expect(s3).toMatchObject({ a: 1, b: 1002 });
const s4 = UR(s3, { type: "@@undoable/redo" });
expect(s4).toMatchObject({ a: 1, b: 1002 });
});
});
/*
TODO:
- historyLimit is enforced
- action filters
*/
+9
View File
@@ -0,0 +1,9 @@
/*
Define globals which are present in the client, but not in node (and therefore not in
the jest test environment).
*/
import { TextDecoder, TextEncoder } from "util";
global.TextDecoder = TextDecoder;
global.TextEncoder = TextEncoder;
@@ -0,0 +1,60 @@
import { rangeEncodeIndices } from "../../src/util/actionHelpers";
describe("rangeEncodeIndices", () => {
test("small array edge cases", () => {
expect(rangeEncodeIndices([])).toMatchObject([]);
expect(rangeEncodeIndices([1])).toMatchObject([1]);
expect(rangeEncodeIndices([1, 99])).toMatchObject([1, 99]);
expect(rangeEncodeIndices([99, 1])).toMatchObject([1, 99]);
expect(rangeEncodeIndices([1, 100, 4])).toMatchObject([1, 4, 100]);
});
test("sorted flag", () => {
expect(rangeEncodeIndices([1, 9, 432], 10, true)).toMatchObject([
1,
9,
432
]);
expect(rangeEncodeIndices([1, 9, 432], 10, false)).toMatchObject([
1,
9,
432
]);
expect(rangeEncodeIndices([0, 1, 2, 3, 9, 10, 432], 2, true)).toMatchObject(
[[0, 3], [9, 10], 432]
);
expect(
rangeEncodeIndices([0, 1, 2, 3, 9, 10, 432], 2, false)
).toMatchObject([[0, 3], [9, 10], 432]);
});
test("begin or end edge cases", () => {
expect(
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12, 99], 3, false)
).toMatchObject([[3, 7], [10, 12], 99]);
expect(
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12], 3, false)
).toMatchObject([[3, 7], [10, 12]]);
expect(
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12], 3, false)
).toMatchObject([0, [3, 7], [10, 12]]);
expect(
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12, 99], 3, false)
).toMatchObject([0, [3, 7], [10, 12], 99]);
});
test("minRangeLength", () => {
expect(
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12, 99], 4, false)
).toMatchObject([[3, 7], 10, 11, 12, 99]);
expect(
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12], 4, false)
).toMatchObject([[3, 7], 10, 11, 12]);
expect(
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12], 4, false)
).toMatchObject([0, [3, 7], 10, 11, 12]);
expect(
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12, 99], 4, false)
).toMatchObject([0, [3, 7], 10, 11, 12, 99]);
});
});
@@ -0,0 +1,652 @@
import * as Dataframe from "../../../src/util/dataframe";
describe("dataframe constructor", () => {
test("empty dataframe", () => {
const df = new Dataframe.Dataframe([0, 0], []);
expect(df).toBeDefined();
expect(df.dims).toEqual([0, 0]);
expect(df).toHaveLength(0);
expect(df.icol(0)).not.toBeDefined();
});
test("create with default indices", () => {
const df = new Dataframe.Dataframe(
[3, 2],
[new Int32Array(3).fill(0), new Int32Array(3).fill(1)]
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 2]);
expect(df.rowIndex).toBeInstanceOf(Dataframe.IdentityInt32Index);
expect(df.colIndex).toBeInstanceOf(Dataframe.IdentityInt32Index);
expect(df.at(0, 0)).toEqual(0);
expect(df.at(2, 1)).toEqual(1);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(2, 1)).toEqual(1);
});
test("create with labelled indices", () => {
const df = new Dataframe.Dataframe(
[3, 2],
[new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])],
new Dataframe.DenseInt32Index([2, 1, 0]),
new Dataframe.KeyIndex(["A", "B"])
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 2]);
expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index);
expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex);
expect(df.rowIndex.keys()).toEqual(new Int32Array([2, 1, 0]));
expect(df.colIndex.keys()).toEqual(["A", "B"]);
expect(df.at(0, "A")).toEqual(2);
expect(df.at(2, "B")).toEqual(3);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(2, 1)).toEqual(5);
});
});
describe("simple data access", () => {
const df = new Dataframe.Dataframe(
[4, 2],
[
new Float64Array([0.0, Number.NaN, Number.POSITIVE_INFINITY, 3.14159]),
["red", "blue", "green", "nan"]
],
new Dataframe.DenseInt32Index([3, 2, 1, 0]),
new Dataframe.KeyIndex(["numbers", "colors"])
);
test("iat", () => {
expect(df).toBeDefined();
// present
expect(df.iat(0, 0)).toEqual(0.0);
expect(df.iat(0, 1)).toEqual("red");
expect(df.iat(1, 0)).toEqual(Number.NaN);
expect(df.iat(1, 1)).toEqual("blue");
expect(df.iat(2, 0)).toEqual(Number.POSITIVE_INFINITY);
expect(df.iat(2, 1)).toEqual("green");
expect(df.iat(3, 0)).toEqual(3.14159);
expect(df.iat(3, 1)).toEqual("nan");
// labels out of range have no defined behavior
});
test("at", () => {
expect(df).toBeDefined();
// present
expect(df.at(3, "numbers")).toEqual(0.0);
expect(df.at(3, "colors")).toEqual("red");
expect(df.at(2, "numbers")).toEqual(Number.NaN);
expect(df.at(2, "colors")).toEqual("blue");
expect(df.at(1, "numbers")).toEqual(Number.POSITIVE_INFINITY);
expect(df.at(1, "colors")).toEqual("green");
expect(df.at(0, "numbers")).toEqual(3.14159);
expect(df.at(0, "colors")).toEqual("nan");
// labels out of range have no defined behavior
});
test("ihas", () => {
expect(df).toBeDefined();
// present
expect(df.ihas(0, 0)).toBeTruthy();
expect(df.ihas(1, 1)).toBeTruthy();
expect(df.ihas(3, 1)).toBeTruthy();
// not present
expect(df.ihas(-1, -1)).toBeFalsy();
expect(df.ihas(0, 99)).toBeFalsy();
expect(df.ihas(99, 0)).toBeFalsy();
expect(df.ihas(99, 99)).toBeFalsy();
expect(df.ihas(-1, 0)).toBeFalsy();
expect(df.ihas(0, -1)).toBeFalsy();
});
test("has", () => {
expect(df).toBeDefined();
// present
expect(df.has(3, "numbers")).toBeTruthy();
expect(df.has(0, "numbers")).toBeTruthy();
expect(df.has(3, "colors")).toBeTruthy();
expect(df.has(0, "colors")).toBeTruthy();
// not present
expect(df.has(3, "foo")).toBeFalsy();
expect(df.has(-1, "numbers")).toBeFalsy();
expect(df.has(-1, -1)).toBeFalsy();
expect(df.has(null, null)).toBeFalsy();
expect(df.has(0, "foo")).toBeFalsy();
expect(df.has(99, "numbers")).toBeFalsy();
expect(df.has(99, "foo")).toBeFalsy();
});
});
describe("dataframe subsetting", () => {
describe("subset", () => {
const sourceDf = new Dataframe.Dataframe(
[3, 4],
[
new Int32Array([0, 1, 2]),
["A", "B", "C"],
new Float32Array([4.4, 5.5, 6.6]),
["red", "green", "blue"]
],
null,
new Dataframe.KeyIndex(["int32", "string", "float32", "colors"])
);
test("all rows, one column", () => {
const dfA = sourceDf.subset(null, ["colors"]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([3, 1]);
expect(dfA.iat(0, 0)).toEqual("red");
expect(dfA.at(2, "colors")).toEqual("blue");
expect(dfA.col("colors").asArray()).toEqual(["red", "green", "blue"]);
expect(dfA.icol(0).asArray()).toEqual(["red", "green", "blue"]);
expect(dfA.col("colors").asArray()).toEqual(
sourceDf.col("colors").asArray()
);
expect(dfA.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfA.colIndex.keys()).toEqual(["colors"]);
});
test("all rows, two columns", () => {
const dfB = sourceDf.subset(null, ["colors", "float32"]);
expect(dfB).toBeDefined();
expect(dfB.dims).toEqual([3, 2]);
expect(dfB.iat(0, 0)).toBeCloseTo(4.4);
expect(dfB.iat(0, 1)).toEqual("red");
expect(dfB.at(2, "colors")).toEqual("blue");
expect(dfB.at(2, "float32")).toBeCloseTo(6.6);
expect(dfB.col("colors").asArray()).toEqual(["red", "green", "blue"]);
expect(dfB.col("float32").asArray()).toEqual(
new Float32Array([4.4, 5.5, 6.6])
);
expect(dfB.icol(0).asArray()).toEqual(dfB.col("float32").asArray());
expect(dfB.icol(1).asArray()).toEqual(dfB.col("colors").asArray());
expect(dfB.col("colors").asArray()).toEqual(
sourceDf.col("colors").asArray()
);
expect(dfB.col("float32").asArray()).toEqual(
sourceDf.col("float32").asArray()
);
expect(dfB.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfB.colIndex.keys()).toEqual(["float32", "colors"]);
});
test("one row, all columns", () => {
const dfC = sourceDf.subset([1], null);
expect(dfC).toBeDefined();
expect(dfC.dims).toEqual([1, 4]);
expect(dfC.iat(0, 0)).toEqual(1);
expect(dfC.iat(0, 1)).toEqual("B");
expect(dfC.iat(0, 2)).toBeCloseTo(5.5);
expect(dfC.iat(0, 3)).toEqual("green");
expect(dfC.rowIndex.keys()).toEqual(new Int32Array([1]));
expect(dfC.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("two rows, all columns", () => {
const dfD = sourceDf.subset([0, 2], null);
expect(dfD).toBeDefined();
expect(dfD.dims).toEqual([2, 4]);
expect(dfD.icol(0).asArray()).toEqual(new Int32Array([0, 2]));
expect(dfD.icol(1).asArray()).toEqual(["A", "C"]);
expect(dfD.icol(2).asArray()).toEqual(new Float32Array([4.4, 6.6]));
expect(dfD.icol(3).asArray()).toEqual(["red", "blue"]);
expect(dfD.rowIndex.keys()).toEqual(new Int32Array([0, 2]));
expect(dfD.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("all rows, all columns", () => {
const dfE = sourceDf.subset(null, null);
expect(dfE).toBeDefined();
expect(dfE.dims).toEqual([3, 4]);
expect(dfE.icol(0).asArray()).toEqual(sourceDf.icol(0).asArray());
expect(dfE.icol(1).asArray()).toEqual(sourceDf.icol(1).asArray());
expect(dfE.icol(2).asArray()).toEqual(sourceDf.icol(2).asArray());
expect(dfE.icol(3).asArray()).toEqual(sourceDf.icol(3).asArray());
expect(dfE.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
expect(dfE.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
});
test("two rows, two colums", () => {
const dfF = sourceDf.subset([0, 2], ["int32", "float32"]);
expect(dfF).toBeDefined();
expect(dfF.dims).toEqual([2, 2]);
expect(dfF.icol(0).asArray()).toEqual(new Int32Array([0, 2]));
expect(dfF.icol(1).asArray()).toEqual(new Float32Array([4.4, 6.6]));
expect(dfF.rowIndex.keys()).toEqual(new Int32Array([0, 2]));
expect(dfF.colIndex.keys()).toEqual(["int32", "float32"]);
});
test("withRowIndex", () => {
const df = sourceDf.subset(
null,
["int32", "float32"],
new Dataframe.DenseInt32Index([3, 2, 1])
);
expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex);
expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index);
expect(df.at(3, "int32")).toEqual(df.iat(0, 0));
});
test("withRowIndex error checks", () => {
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.IdentityInt32Index(1))
).toThrow(RangeError);
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.DenseInt32Index([0, 1]))
).toThrow(RangeError);
expect(() =>
sourceDf.subset(null, ["red"], new Dataframe.KeyIndex([0, 1, 2, 3]))
).toThrow(RangeError);
});
});
test("isubsetMask", () => {
const sourceDf = new Dataframe.Dataframe(
[3, 4],
[
new Int32Array([0, 1, 2]),
["A", "B", "C"],
new Float32Array([4.4, 5.5, 6.6]),
["red", "green", "blue"]
],
new Dataframe.DenseInt32Index([2, 4, 6]),
new Dataframe.KeyIndex(["int32", "string", "float32", "colors"])
);
const dfA = sourceDf.isubsetMask(
new Uint8Array([0, 1, 1]),
new Uint8Array([1, 0, 0, 1])
);
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(new Int32Array([1, 2]));
expect(dfA.icol(1).asArray()).toEqual(["green", "blue"]);
expect(dfA.rowIndex.keys()).toEqual(new Int32Array([4, 6]));
expect(dfA.colIndex.keys()).toEqual(["int32", "colors"]);
});
});
describe("dataframe factories", () => {
test("create", () => {
const df = Dataframe.Dataframe.create(
[3, 3],
[
new Array(3).fill(0),
new Int16Array(3).fill(99),
new Float64Array(3).fill(1.1)
]
);
expect(df).toBeDefined();
expect(df.dims).toEqual([3, 3]);
expect(df).toHaveLength(3);
expect(df.iat(0, 0)).toEqual(0);
expect(df.iat(1, 1)).toEqual(99);
expect(df.iat(2, 2)).toBeCloseTo(1.1);
expect(df.iat(0, 0)).toEqual(df.at(0, 0));
expect(df.iat(1, 1)).toEqual(df.at(1, 1));
expect(df.iat(2, 2)).toEqual(df.at(2, 2));
});
test("clone", () => {
const dfA = new Dataframe.Dataframe(
[3, 2],
[new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])],
new Dataframe.DenseInt32Index([2, 1, 0]),
new Dataframe.KeyIndex(["A", "B"])
);
const dfB = dfA.clone();
expect(dfB).not.toBe(dfA);
expect(dfB.dims).toEqual(dfA.dims);
expect(dfB).toHaveLength(dfA.length);
expect(dfB.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
expect(dfB.colIndex.keys()).toEqual(dfA.colIndex.keys());
for (let i = 0, l = dfB.dims[1]; i < l; i += 1) {
expect(dfB.icol(i).asArray()).toEqual(dfA.icol(i).asArray());
}
});
describe("withCol", () => {
test("KeyIndex", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.KeyIndex(["colors", "bools"])
);
const dfA = df.withCol("numbers", [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col("numbers").asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(["colors", "bools", "numbers"]);
expect(df.colIndex.keys()).toEqual(["colors", "bools"]);
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.DenseInt32Index([74, 75])
);
const dfA = df.withCol(72, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(74).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(75).asArray()).toEqual([true, false]);
expect(dfA.col(72).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([74, 75, 72]));
expect(df.colIndex.keys()).toEqual(new Int32Array([74, 75]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index promote", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
new Dataframe.DenseInt32Index([74, 75])
);
const dfA = df.withCol(999, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(74).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(75).asArray()).toEqual([true, false]);
expect(dfA.col(999).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([74, 75, 999]));
expect(df.colIndex.keys()).toEqual(new Int32Array([74, 75]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index with last", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
null
);
const dfA = df.withCol(2, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(1).asArray()).toEqual([true, false]);
expect(dfA.col(2).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index promote", () => {
const df = new Dataframe.Dataframe(
[2, 2],
[["red", "blue"], [true, false]],
null,
null
);
const dfA = df.withCol(99, [1, 0]);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 3]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
expect(dfA.col(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.col(1).asArray()).toEqual([true, false]);
expect(dfA.col(99).asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1, 99]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
describe("handle column dimensions correctly", () => {
/*
there are two conditions:
- empty dataframe - will accept an add of any dimensionality
- non-empty dataframe - added column must match row-count dimension
*/
test("empty.withCol", () => {
const edf = Dataframe.Dataframe.empty();
const df = edf.withCol("foo", [1, 2, 3]);
expect(edf).toBeDefined();
expect(df).toBeDefined();
expect(edf).not.toEqual(df);
expect(df.dims).toEqual([3, 1]);
expect(df.icol(0).asArray()).toEqual([1, 2, 3]);
});
test("withCol dimension check", () => {
const dfA = new Dataframe.Dataframe([1, 1], [["a"]]);
expect(() => {
dfA.withCol(1, []);
}).toThrow(RangeError);
});
});
});
describe("dropCol", () => {
test("KeyIndex", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
);
const dfA = df.dropCol("colors");
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual([true, false]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(dfA.col("numbers").asArray()).toEqual([1, 0]);
expect(dfA.colIndex.keys()).toEqual(["bools", "numbers"]);
expect(df.colIndex.keys()).toEqual(["colors", "bools", "numbers"]);
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index drop first", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
null
);
const dfA = df.dropCol(0);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual([true, false]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(df.col(1).asArray()).toEqual(dfA.col(1).asArray());
expect(df.col(2).asArray()).toEqual(dfA.col(2).asArray());
expect(dfA.colIndex.keys()).toEqual(new Int32Array([1, 2]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("IdentityInt32Index drop last", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
null
);
const dfA = df.dropCol(2);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([true, false]);
expect(df.col(0).asArray()).toEqual(dfA.col(0).asArray());
expect(df.col(1).asArray()).toEqual(dfA.col(1).asArray());
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1]));
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
test("DenseInt32Index", () => {
const df = new Dataframe.Dataframe(
[2, 3],
[["red", "blue"], [true, false], [1, 0]],
null,
new Dataframe.DenseInt32Index([102, 101, 100])
);
const dfA = df.dropCol(101);
expect(dfA).toBeDefined();
expect(dfA.dims).toEqual([2, 2]);
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
expect(dfA.col(100).asArray()).toEqual([1, 0]);
expect(dfA.col(102).asArray()).toEqual(["red", "blue"]);
expect(dfA.colIndex.keys()).toEqual(new Int32Array([102, 100]));
expect(df.colIndex.keys()).toEqual(new Int32Array([102, 101, 100]));
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
});
describe("mapColumns", () => {
test("identity", () => {
const dfA = Dataframe.Dataframe.create(
[3, 3],
[
new Array(3).fill(0),
new Int16Array(3).fill(99),
new Float64Array(3).fill(1.1)
]
);
const dfB = dfA.mapColumns((col, idx) => {
expect(dfA.icol(idx).asArray()).toBe(col);
return col;
});
expect(dfA).not.toBe(dfB);
expect(dfA.dims).toEqual(dfB.dims);
for (let c = 0; c < dfA.dims[1]; c += 1) {
expect(dfA.icol(c).asArray()).toBe(dfB.icol(c).asArray());
}
});
test("transform", () => {
const dfA = Dataframe.Dataframe.create(
[3, 3],
[new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)]
);
const dfB = dfA.mapColumns(() => {
return new Array(3).fill(1);
});
expect(dfA).not.toBe(dfB);
expect(dfB.iat(0, 0)).toEqual(1);
expect(dfB.iat(0, 1)).toEqual(1);
expect(dfB.iat(0, 2)).toEqual(1);
});
});
});
describe("dataframe col", () => {
let df = null;
beforeEach(() => {
df = new Dataframe.Dataframe(
[2, 2],
[[true, false], [1, 0]],
null,
new Dataframe.KeyIndex(["A", "B"])
);
});
test("col", () => {
expect(df).toBeDefined();
expect(df.col("A")).toBe(df.icol(0));
expect(df.col("B")).toBe(df.icol(1));
expect(df.col("undefined")).toBeUndefined();
expect(df.icol("undefined")).toBeUndefined();
const colA = df.col("A");
expect(colA).toBeInstanceOf(Function);
expect(colA.asArray).toBeInstanceOf(Function);
expect(colA.has).toBeInstanceOf(Function);
expect(colA.ihas).toBeInstanceOf(Function);
expect(colA.indexOf).toBeInstanceOf(Function);
expect(colA.iget).toBeInstanceOf(Function);
});
test("col.asArray", () => {
expect(df).toBeDefined();
expect(df.col("A").asArray()).toEqual([true, false]);
expect(df.icol(0).asArray()).toEqual([true, false]);
expect(df.col("B").asArray()).toEqual([1, 0]);
expect(df.icol(1).asArray()).toEqual([1, 0]);
});
test("col.has", () => {
expect(df).toBeDefined();
expect(df.col("A").has(-1)).toBe(false);
expect(df.col("A").has(0)).toBe(true);
expect(df.col("A").has(1)).toBe(true);
expect(df.col("A").has(2)).toBe(false);
expect(df.col("B").has(-1)).toBe(false);
expect(df.col("B").has(0)).toBe(true);
expect(df.col("B").has(1)).toBe(true);
expect(df.col("B").has(2)).toBe(false);
});
test("col.ihas", () => {
expect(df).toBeDefined();
expect(df.col("A").ihas(-1)).toBe(false);
expect(df.col("A").ihas(0)).toBe(true);
expect(df.col("A").ihas(1)).toBe(true);
expect(df.col("A").ihas(2)).toBe(false);
expect(df.col("B").ihas(-1)).toBe(false);
expect(df.col("B").ihas(0)).toBe(true);
expect(df.col("B").ihas(1)).toBe(true);
expect(df.col("B").ihas(2)).toBe(false);
});
test("col.iget", () => {
expect(df).toBeDefined();
expect(df.col("A").iget(0)).toEqual(df.iat(0, 0));
expect(df.col("B").iget(1)).toEqual(df.iat(1, 1));
});
test("col.indexOf", () => {
expect(df).toBeDefined();
expect(df.col("A").indexOf(true)).toEqual(0);
expect(df.col("A").indexOf(false)).toEqual(1);
expect(df.col("A").indexOf(99)).toBeUndefined();
expect(df.col("A").indexOf(undefined)).toBeUndefined();
expect(df.col("A").indexOf(1)).toBeUndefined();
expect(df.col("B").indexOf(1)).toEqual(0);
expect(df.col("B").indexOf(0)).toEqual(1);
expect(df.col("B").indexOf(99)).toBeUndefined();
expect(df.col("B").indexOf(undefined)).toBeUndefined();
expect(df.col("B").indexOf(true)).toBeUndefined();
});
});
@@ -0,0 +1,253 @@
import * as Dataframe from "../../../src/util/dataframe";
function float32Conversion(f) {
return new Float32Array([f])[0];
}
describe("Dataframe column summary", () => {
test("empty column test", () => {
const df = Dataframe.Dataframe.create([0, 1], [[]]);
const summary = df.icol(0).summarize();
expect(summary).toEqual(
expect.objectContaining({
categorical: true,
categories: [],
categoryCounts: new Map(),
numCategories: 0
})
);
});
test("simple test", () => {
const df = new Dataframe.Dataframe(
[1, 6],
[
["n1"],
["hi"],
[true],
new Float32Array([39.3]),
new Int32Array([99]),
[1]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: ["n1"],
categoryCounts: new Map([["n1", 1]]),
numCategories: 1
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: ["hi"],
categoryCounts: new Map([["hi", 1]]),
numCategories: 1
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: [true],
categoryCounts: new Map([[true, 1]]),
numCategories: 1
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: float32Conversion(39.3),
max: float32Conversion(39.3),
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: [1],
categoryCounts: new Map([[1, 1]]),
numCategories: 1
})
);
});
test("multi test", () => {
const df = new Dataframe.Dataframe(
[3, 6],
[
["n0", "n1", "n2"],
["hi", "hi", "bye"],
[false, true, true],
new Float32Array([39.3, 39.3, 0]),
new Int32Array([99, 99, 99]),
[1, false, "0"]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["n0", "n1", "n2"]),
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 1]]),
numCategories: 3
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["hi", "bye"]),
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
numCategories: 2
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([true, false]),
categoryCounts: new Map([[true, 2], [false, 1]]),
numCategories: 2
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 0,
max: float32Conversion(39.3),
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([1, false, "0"]),
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
numCategories: 3
})
);
});
test("non-finite numbers", () => {
const df = new Dataframe.Dataframe(
[4, 6],
[
["n0", "n1", "n2", "n2"],
["hi", "hi", "bye", "bye"],
[false, true, true, true],
new Float32Array([
39.3,
Number.NEGATIVE_INFINITY,
Number.NaN,
Number.POSITIVE_INFINITY
]),
new Int32Array([99, 99, 99, 99]),
[1, false, "0", "0"]
],
null,
new Dataframe.KeyIndex([
"name",
"nameString",
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
])
);
expect(df.icol(0).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["n0", "n1", "n2"]),
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 2]]),
numCategories: 3
})
);
expect(df.icol(1).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["hi", "bye"]),
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
numCategories: 2
})
);
expect(df.icol(2).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([true, false]),
categoryCounts: new Map([[true, 2], [false, 1]]),
numCategories: 2
})
);
expect(df.icol(3).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: float32Conversion(39.3),
max: float32Conversion(39.3),
nan: 1,
ninf: 1,
pinf: 1
})
);
expect(df.icol(4).summarize()).toEqual(
expect.objectContaining({
categorical: false,
min: 99,
max: 99,
nan: 0,
ninf: 0,
pinf: 0
})
);
expect(df.icol(5).summarize()).toEqual(
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining([1, false, "0"]),
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
numCategories: 3
})
);
});
});
@@ -0,0 +1,68 @@
/*
Test cases for nameCreators.js.
*/
import {
layoutDimensionName,
obsAnnoDimensionName,
diffexpDimensionName,
userDefinedDimensionName,
makeContinuousDimensionName
} from "../../src/util/nameCreators";
describe("nameCreators", () => {
const nameCreators = [
layoutDimensionName,
obsAnnoDimensionName,
diffexpDimensionName,
userDefinedDimensionName
];
test("check for namespace isolation", () => {
const foo = "foo";
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(foo));
const bar = "bar";
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(fn(bar)));
nameCreators.forEach(fn => {
const allOtherFn = nameCreators.filter(elmnt => elmnt !== fn);
allOtherFn.forEach(otherFn => expect(fn(foo)).not.toBe(otherFn(foo)));
});
});
test("check for legal keys", () => {
/* need namespace creators to return strings only */
nameCreators.forEach(fn => expect(fn("X")).toMatch(/X/));
});
});
describe("makeContinuousDimensionName", () => {
test("namespaces", () => {
expect(makeContinuousDimensionName({ isObs: true }, "OK")).toMatch(/OK/);
expect(makeContinuousDimensionName({ isDiffExp: true }, "OK")).toMatch(
/OK/
);
expect(makeContinuousDimensionName({ isUserDefined: true }, "OK")).toMatch(
/OK/
);
});
test("error handling", () => {
expect(() => makeContinuousDimensionName({}, "oops")).toThrow();
expect(() =>
makeContinuousDimensionName(
{ isObs: false, isDiffExp: false, isUserDefined: false },
"oops"
)
).toThrow();
expect(() =>
makeContinuousDimensionName({ isObs: true }, "OK")
).not.toThrow();
expect(() =>
makeContinuousDimensionName({ isDiffExp: true }, "OK")
).not.toThrow();
expect(() =>
makeContinuousDimensionName({ isUserDefined: true }, "OK")
).not.toThrow();
});
});
+29
View File
@@ -0,0 +1,29 @@
import quantile from "../../src/util/quantile";
describe("quantile", () => {
test("single q", () => {
const arr = new Float32Array([9, 3, 5, 6, 0]);
expect(quantile([1.0], arr)).toMatchObject([9]);
expect(quantile([0.9], arr)).toMatchObject([9]);
expect(quantile([0.8], arr)).toMatchObject([9]);
expect(quantile([0.7], arr)).toMatchObject([6]);
expect(quantile([0.6], arr)).toMatchObject([6]);
expect(quantile([0.5], arr)).toMatchObject([5]);
expect(quantile([0.4], arr)).toMatchObject([5]);
expect(quantile([0.3], arr)).toMatchObject([3]);
expect(quantile([0.2], arr)).toMatchObject([3]);
expect(quantile([0.1], arr)).toMatchObject([0]);
expect(quantile([0], arr)).toMatchObject([0]);
});
test("multi q", () => {
const arr = new Float32Array([9, 3, 5, 6, 0]);
expect(quantile([0, 0.25, 0.5, 0.75, 1.0], arr)).toMatchObject([
0,
3,
5,
6,
9
]);
});
});
@@ -0,0 +1,205 @@
/* eslint no-bitwise: "off" */
import _ from "lodash";
import { flatbuffers } from "flatbuffers";
import { NetEncoding } from "../../../src/util/stateManager/matrix_generated";
/*
test data mocking REST 0.2 API responses. Used in several tests.
*/
const nObs = 10;
const nVar = 32;
const field4Categories = [83, true, "foo", 2.222222];
const fieldDCategories = [99, false, "mumble", 3.1415];
const aConfigResponse = {
config: {
features: [
{ method: "POST", path: "/cluster/", available: false },
{ method: "POST", path: "/layout/", available: false },
{ method: "POST", path: "/diffexp/", available: false },
{ method: "POST", path: "/saveLocal/", available: false }
],
displayNames: {
engine: "the little engine that could",
dataset: "all your zeros are mine"
}
}
};
const aSchemaResponse = {
schema: {
dataframe: {
nObs,
nVar,
type: "float32"
},
annotations: {
obs: [
{ name: "name", type: "string" },
{ name: "field1", type: "int32" },
{ name: "field2", type: "float32" },
{ name: "field3", type: "boolean" },
{
name: "field4",
type: "categorical",
categories: field4Categories
}
],
var: [
{ name: "name", type: "string" },
{ name: "fieldA", type: "int32" },
{ name: "fieldB", type: "float32" },
{ name: "fieldC", type: "boolean" },
{
name: "fieldD",
type: "categorical",
categories: fieldDCategories
}
]
}
}
};
const anAnnotationsObsJSONResponse = {
names: ["name", "field1", "field2", "field3", "field4"],
data: _()
.range(nObs)
.map(idx => [
idx,
`obs${idx}`,
2 * idx,
idx + 0.0133,
!!(idx & 1),
field4Categories[idx % field4Categories.length]
])
.value()
};
const anAnnotationsVarJSONResponse = {
names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"],
data: _()
.range(nVar)
.map(idx => [
idx,
10 * idx,
idx + 2.90143,
!!(idx & 1),
fieldDCategories[idx % fieldDCategories.length],
`var${idx}`
])
.value()
};
function encodeTypedArray(builder, uType, uData) {
const uTypeName = NetEncoding.TypedArray[uType];
const ArrayType = NetEncoding[uTypeName];
const dv = ArrayType.createDataVector(builder, uData);
builder.startObject(1);
builder.addFieldOffset(0, dv, 0);
return builder.endObject();
}
function encodeMatrix(columns, colIndex = undefined) {
const utf8Encoder = new TextEncoder("utf-8");
const builder = new flatbuffers.Builder(1024);
const cols = _.map(columns, carr => {
let uType;
let tarr;
if (_.every(carr, _.isNumber)) {
uType = NetEncoding.TypedArray.Float32Array;
tarr = encodeTypedArray(builder, uType, new Float32Array(carr));
} else {
uType = NetEncoding.TypedArray.JSONEncodedArray;
const json = JSON.stringify(carr);
const jsonUTF8 = utf8Encoder.encode(json);
tarr = encodeTypedArray(builder, uType, jsonUTF8);
}
NetEncoding.Column.startColumn(builder);
NetEncoding.Column.addUType(builder, uType);
NetEncoding.Column.addU(builder, tarr);
return NetEncoding.Column.endColumn(builder);
});
const encColumns = NetEncoding.Matrix.createColumnsVector(builder, cols);
let encColIndex;
if (colIndex) {
encColIndex = encodeTypedArray(
builder,
NetEncoding.TypedArray.JSONEncodedArray,
utf8Encoder.encode(JSON.stringify(colIndex))
);
}
NetEncoding.Matrix.startMatrix(builder);
NetEncoding.Matrix.addNRows(builder, columns[0].length);
NetEncoding.Matrix.addNCols(builder, columns.length);
NetEncoding.Matrix.addColumns(builder, encColumns);
if (colIndex) {
NetEncoding.Matrix.addColIndexType(
builder,
NetEncoding.TypedArray.JSONEncodedArray
);
NetEncoding.Matrix.addColIndex(builder, encColIndex);
}
const root = NetEncoding.Matrix.endMatrix(builder);
builder.finish(root);
return builder.asUint8Array();
}
const anAnnotationsObsFBSResponse = (() => {
const columns = _.zip(...anAnnotationsObsJSONResponse.data).slice(1);
return encodeMatrix(columns, anAnnotationsObsJSONResponse.names);
})();
const anAnnotationsVarFBSResponse = (() => {
const columns = _.zip(...anAnnotationsVarJSONResponse.data).slice(1);
return encodeMatrix(columns, anAnnotationsVarJSONResponse.names);
})();
const aLayoutFBSResponse = (() => {
const coords = [
new Float32Array(nObs).fill(Math.random()),
new Float32Array(nObs).fill(Math.random())
];
const builder = new flatbuffers.Builder(1024);
const cols = _.map(coords, carr => {
const cdv = NetEncoding.Float32Array.createDataVector(builder, carr);
NetEncoding.Float32Array.startFloat32Array(builder);
NetEncoding.Float32Array.addData(builder, cdv);
const floatArr = NetEncoding.Float32Array.endFloat32Array(builder);
NetEncoding.Column.startColumn(builder);
NetEncoding.Column.addUType(builder, NetEncoding.TypedArray.Float32Array);
NetEncoding.Column.addU(builder, floatArr);
return NetEncoding.Column.endColumn(builder);
});
const columns = NetEncoding.Matrix.createColumnsVector(builder, cols);
NetEncoding.Matrix.startMatrix(builder);
NetEncoding.Matrix.addNRows(builder, nObs);
NetEncoding.Matrix.addNCols(builder, coords.length);
NetEncoding.Matrix.addColumns(builder, columns);
const matrix = NetEncoding.Matrix.endMatrix(builder);
builder.finish(matrix);
return builder.asUint8Array();
})();
const aDataObsResponse = {
var: [2, 4, 29],
obs: _()
.range(nObs)
.map(idx => [idx, Math.random(), Math.random(), Math.random()])
.value()
};
export {
aLayoutFBSResponse as layoutObs,
aDataObsResponse as dataObs,
anAnnotationsVarFBSResponse as annotationsVar,
anAnnotationsObsFBSResponse as annotationsObs,
aSchemaResponse as schema,
aConfigResponse as config
};
@@ -0,0 +1,66 @@
import * as Universe from "../../../src/util/stateManager/universe";
import * as Dataframe from "../../../src/util/dataframe";
import * as REST from "./sampleResponses";
describe("createUniverseFromResponse", () => {
/*
test createUniverseFromResponse - this function converts
a set of REST 0.2 responses into a "new" Universe.
createUniverseFromResponse(
configResponse,
schemaResponse,
annotationsObsResponse,
annotationsVarResponse,
layoutObsResponse
) --> Universe
where:
configResponse: GET /.../config
schemaResponse: GET /.../schema
annotationsObsResponse: GET /.../annotations/obs
annotationsVarResponse: GET /.../annotations/var
layoutObsResponse: GET /.../layout/obs
See spec in docs/REST_API.md.
*/
test("create from test data", () => {
/*
create a universe from sample data nad validate its shape & contents
*/
const { nObs, nVar } = REST.schema.schema.dataframe;
const universe = Universe.createUniverseFromResponse(
REST.config,
REST.schema,
REST.annotationsObs,
REST.annotationsVar,
REST.layoutObs
);
expect(universe).toBeDefined();
expect(universe).toMatchObject(
expect.objectContaining({
nObs,
nVar,
schema: REST.schema.schema,
obsAnnotations: expect.any(Dataframe.Dataframe),
varAnnotations: expect.any(Dataframe.Dataframe),
obsLayout: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe)
})
);
expect(universe.obsAnnotations.dims).toEqual([
nObs,
REST.schema.schema.annotations.obs.length
]);
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
expect(universe.obsLayout.colIndex.keys()).toEqual(["X", "Y"]);
expect(universe.varAnnotations.dims).toEqual([
nVar,
REST.schema.schema.annotations.var.length
]);
expect(universe.varData.isEmpty()).toBeTruthy();
});
});
@@ -0,0 +1,183 @@
import _ from "lodash";
import * as Universe from "../../../src/util/stateManager/universe";
import * as World from "../../../src/util/stateManager/world";
import * as Dataframe from "../../../src/util/dataframe";
import Crossfilter from "../../../src/util/typedCrossfilter";
import { DimTypes } from "../../../src/util/typedCrossfilter/crossfilter";
import * as REST from "./sampleResponses";
import {
obsAnnoDimensionName,
layoutDimensionName
} from "../../../src/util/nameCreators";
/*
Helper - creates universe, world, corssfilter and dimensionMap from
the default REST test response.
*/
const defaultBigBang = () => {
/* create unverse, world, crossfilter and dimensionMap */
/* create universe */
const universe = Universe.createUniverseFromResponse(
REST.config,
REST.schema,
REST.annotationsObs,
REST.annotationsVar,
REST.layoutObs
);
/* create world */
const world = World.createWorldFromEntireUniverse(universe);
/* create crossfilter */
const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations),
world
);
return {
universe,
world,
crossfilter
};
};
describe("createWorldFromEntireUniverse", () => {
test("create from REST sample", () => {
const universe = Universe.createUniverseFromResponse(
REST.config,
REST.schema,
REST.annotationsObs,
REST.annotationsVar,
REST.layoutObs
);
expect(universe).toBeDefined();
const world = World.createWorldFromEntireUniverse(universe);
expect(world).toBeDefined();
expect(world).toMatchObject(
expect.objectContaining({
nObs: universe.nObs,
nVar: universe.nVar,
schema: universe.schema,
obsAnnotations: expect.any(Dataframe.Dataframe),
varAnnotations: expect.any(Dataframe.Dataframe),
obsLayout: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe),
clipQuantiles: { min: 0, max: 1 },
unclipped: {
obsAnnotations: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe)
}
})
);
});
});
describe("createWorldFromCurrentSelection", () => {
test("create from REST sample", () => {
const {
universe,
world: originalWorld,
crossfilter: originalCrossfilter
} = defaultBigBang();
/* mock a selection */
const crossfilter = originalCrossfilter
.select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 })
.select(obsAnnoDimensionName("field3"), {
mode: "exact",
values: [false]
});
/* create the world from the selection */
const world = World.createWorldBySelection(
universe,
originalWorld,
crossfilter
);
expect(world).toBeDefined();
expect(world.nObs).toEqual(crossfilter.countSelected());
/*
calculate expected values and match against result
*/
/* matchFilter must match the dimension filters above */
const matchFilter = (df, row) => {
const field1 = df.at(row, "field1");
const field3 = df.at(row, "field3");
return field1 >= 0 && field1 < 5 && !field3;
};
const matchingIndices = _()
.range(universe.nObs)
.filter(idx => matchFilter(universe.obsAnnotations, idx))
.value();
expect(world).toMatchObject(
expect.objectContaining({
nObs: matchingIndices.length,
nVar: universe.nVar,
schema: universe.schema,
clipQuantiles: { min: 0, max: 1 },
obsAnnotations: expect.any(Dataframe.Dataframe),
varAnnotations: expect.any(Dataframe.Dataframe),
obsLayout: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe),
unclipped: {
obsAnnotations: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe)
}
})
);
expect(world.obsAnnotations.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices)
);
expect(world.obsAnnotations.colIndex.keys()).toEqual(
universe.obsAnnotations.colIndex.keys()
);
expect(world.obsLayout.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices)
);
expect(world.obsLayout.colIndex.keys()).toEqual(["X", "Y"]);
});
});
describe("createObsDimensionMap", () => {
test("when universe eq world", () => {
/*
check for:
- creates a dimension for all obsAnnotations, PLUS X/Y layout
- check that dimension typing is sane
*/
const { crossfilter } = defaultBigBang();
const annotationNames = _.map(
REST.schema.schema.annotations.obs,
c => c.name
);
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name");
expect(crossfilter).toBeDefined();
annotationNames.forEach(name => {
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
if (name === "name") {
expect(dim).toBeUndefined();
} else {
const { type } = schemaByObsName[name];
if (type === "string" || type === "boolean" || type === "categorical") {
expect(dim.dim).toBeInstanceOf(DimTypes.enum);
} else {
expect(dim.dim).toBeInstanceOf(DimTypes.scalar);
}
}
});
expect(
crossfilter.dimensions[layoutDimensionName("XY")].dim
).toBeInstanceOf(DimTypes.spatial);
});
});
describe("worldEqUniverse", () => {
const { universe, world } = defaultBigBang();
const result = World.worldEqUniverse(world, universe);
expect(result).toBe(true);
});
@@ -0,0 +1,62 @@
import {
countCategoryValues2D,
clearCaches
} from "../../../src/util/stateManager/worldUtil";
import * as Dataframe from "../../../src/util/dataframe";
describe("WorldUtil cache management", () => {
test("empty", () => {
const count = countCategoryValues2D(
"a",
"b",
new Dataframe.Dataframe([0, 0], [])
);
expect(count).toMatchObject(new Map());
expect(count.size).toBe(0);
});
test("simple couts", () => {
const df = new Dataframe.Dataframe(
[3, 2],
[[0, 0, 1], [false, true, false]],
null,
new Dataframe.KeyIndex(["a", "b"])
);
const count = countCategoryValues2D("a", "b", df);
expect(count).toMatchObject(
new Map([
[0, new Map([[true, 1], [false, 1]])],
[1, new Map([[false, 1]])]
])
);
});
test("memo cache clear", () => {
clearCaches();
const df1 = new Dataframe.Dataframe([0, 0], []);
const df2 = new Dataframe.Dataframe(
[3, 2],
[[0, 0, 1], [false, true, false]],
null,
new Dataframe.KeyIndex(["a", "b"])
);
const count1 = countCategoryValues2D("a", "b", df1);
const count2 = countCategoryValues2D("a", "b", df1);
const count3 = countCategoryValues2D("a", "b", df1.clone());
const count4 = countCategoryValues2D("a", "b", df2);
clearCaches();
const count10 = countCategoryValues2D("a", "b", df1);
const count11 = countCategoryValues2D("a", "b", df2);
expect(count1).toEqual(count2);
expect(count1).toEqual(count3);
expect(count1).toEqual(count10);
expect(count1).not.toBe(count3);
expect(count1).not.toBe(count10);
expect(count4).toEqual(count11);
expect(count4).not.toBe(count11);
});
});
@@ -0,0 +1,185 @@
// jshint esversion: 6
import BitArray from "../../../src/util/typedCrossfilter/bitArray";
const defaultTestLength = 8;
describe("default select state", () => {
test("newly created Bitarray should be deselected", () => {
const ba = new BitArray(defaultTestLength);
expect(ba).toBeDefined();
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
const dim = ba.allocDimension();
expect(dim).toBeDefined();
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
ba.freeDimension(dim);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
});
});
describe("select and deselect", () => {
test("selectAll and deselectAll", () => {
const ba = new BitArray(defaultTestLength);
expect(ba).toBeDefined();
const dim1 = ba.allocDimension();
expect(dim1).toBeDefined();
ba.selectAll(dim1);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(true);
}
const dim2 = ba.allocDimension();
expect(dim2).toBeDefined();
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
ba.selectAll(dim2);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(true);
}
ba.deselectAll(dim1);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
ba.deselectAll(dim2);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
ba.selectAll(dim1);
ba.selectAll(dim2);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(true);
}
ba.freeDimension(dim1);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(true);
}
ba.freeDimension(dim2);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
});
test("selectOne and deselectOne", () => {
const ba = new BitArray(defaultTestLength);
expect(ba).toBeDefined();
const dim = ba.allocDimension();
expect(dim).toBeDefined();
ba.selectOne(dim, 0);
expect(ba.isSelected(0)).toEqual(true);
for (let i = 1; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
ba.deselectOne(dim, 0);
for (let i = 0; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
ba.selectOne(dim, 1);
expect(ba.isSelected(1)).toEqual(true);
expect(ba.isSelected(0)).toEqual(false);
for (let i = 2; i < defaultTestLength; i += 1) {
expect(ba.isSelected(i)).toEqual(false);
}
ba.selectAll(dim);
ba.deselectOne(dim, defaultTestLength - 1);
expect(ba.isSelected(defaultTestLength - 1)).toEqual(false);
for (let i = 0; i < defaultTestLength - 1; i += 1) {
expect(ba.isSelected(i)).toEqual(true);
}
});
});
describe("selectionCount", () => {
test("simple", () => {
const ba = new BitArray(defaultTestLength);
expect(ba).toBeDefined();
const dim1 = ba.allocDimension();
expect(dim1).toBeDefined();
const dim2 = ba.allocDimension();
expect(dim2).toBeDefined();
expect(ba.selectionCount()).toEqual(0);
ba.selectAll(dim1);
expect(ba.selectionCount()).toEqual(0);
ba.selectAll(dim2);
expect(ba.selectionCount()).toEqual(defaultTestLength);
for (let i = 0; i < defaultTestLength; i += 1) {
ba.deselectOne(dim1, i);
expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1);
expect(ba.selectionCount()).toEqual(ba.countAllOnes());
}
ba.freeDimension(dim1);
ba.freeDimension(dim2);
});
});
describe("fillBySelection", () => {
test("sets values correctly", () => {
const ba = new BitArray(defaultTestLength);
expect(ba).toBeDefined();
const dim = ba.allocDimension();
expect(dim).toBeDefined();
const arr = new Int32Array(defaultTestLength);
arr.fill(0);
const truth = new Int32Array(defaultTestLength);
truth.fill(0);
// initial state should be deselected
ba.fillBySelection(arr, 1, 0);
expect(arr).toEqual(expect.not.arrayContaining([1]));
// selectAll
ba.selectAll(dim);
ba.fillBySelection(arr, 1, 0);
expect(arr).toEqual(expect.not.arrayContaining([0]));
// deselectOne
ba.deselectOne(dim, 3);
ba.fillBySelection(arr, 1, 0);
truth.fill(1);
truth[3] = 0;
expect(arr).toEqual(truth);
// deselectAll
ba.deselectAll(dim);
ba.fillBySelection(arr, 1, 0);
truth.fill(0);
expect(arr).toEqual(truth);
// selectOne
ba.selectOne(dim, 5);
ba.fillBySelection(arr, 6, 1);
truth.fill(1);
truth[5] = 6;
expect(arr).toEqual(truth);
// should be deselected after dimension disposal
ba.freeDimension(dim);
ba.fillBySelection(arr, 3, 9);
truth.fill(9);
expect(arr).toEqual(truth);
});
});
@@ -0,0 +1,434 @@
import _ from "lodash";
import { polygonContains } from "d3";
import Crossfilter from "../../../src/util/typedCrossfilter";
const someData = [
{
date: "2011-11-14T16:17:54Z",
quantity: 2,
total: 190,
tip: 100,
type: "tab",
productIDs: ["001"],
coords: [0, 0],
nonFinite: 0.0
},
{
date: "2011-11-14T16:20:19Z",
quantity: 2,
total: 190,
tip: 100,
type: "tab",
productIDs: ["001", "005"],
coords: [0.4, 0.4],
nonFinite: Number.NaN
},
{
date: "2011-11-14T16:28:54Z",
quantity: 1,
total: 300,
tip: 200,
type: "visa",
productIDs: ["004", "005"],
coords: [0.3, 0.1],
nonFinite: Number.POSITIVE_INFINITY
},
{
date: "2011-11-14T16:30:43Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "002"],
coords: [0.392, 0.1],
nonFinite: Number.NEGATIVE_INFINITY
},
{
date: "2011-11-14T16:48:46Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["005"],
coords: [0.7, 0.0482],
nonFinite: 1.0
},
{
date: "2011-11-14T16:53:41Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "004", "005"],
coords: [0.9999, 1.0],
nonFinite: Number.NaN
},
{
date: "2011-11-14T16:54:06Z",
quantity: 1,
total: 100,
tip: 0,
type: "cash",
productIDs: ["001", "002", "003", "004", "005"],
coords: [0.384, 0.6938],
nonFinite: 99.0
},
{
date: "2011-11-14T16:58:03Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001"],
coords: [0.4822, 0.482],
nonFinite: Number.NaN
},
{
date: "2011-11-14T17:07:21Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["004", "005"],
coords: [0.2234, 0],
nonFinite: Number.NaN
},
{
date: "2011-11-14T17:22:59Z",
quantity: 2,
total: 90,
tip: 0,
type: "tab",
productIDs: ["001", "002", "004", "005"],
coords: [0.382, 0.38485],
nonFinite: -1
},
{
date: "2011-11-14T17:25:45Z",
quantity: 2,
total: 200,
tip: 0,
type: "cash",
productIDs: ["002"],
coords: [0.998, 0.8472],
nonFinite: 0.0
},
{
date: "2011-11-14T17:29:52Z",
quantity: 1,
total: 200,
tip: 100,
type: "visa",
productIDs: ["004"],
coords: [0.8273, 0.3384],
nonFinite: 0.0
}
];
let payments = null;
beforeEach(() => {
payments = new Crossfilter(someData);
});
describe("ImmutableTypedCrossfilter", () => {
test("create crossfilter", () => {
expect(payments).toBeDefined();
expect(payments.size()).toEqual(someData.length);
expect(payments.all()).toEqual(someData);
const p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.select("quantity", { mode: "all" });
expect(p).toBeDefined();
expect(p.all()).toEqual(someData);
expect(p.size()).toEqual(someData.length);
expect(p.isElementSelected(0)).toBeTruthy();
expect(p.countSelected()).toEqual(someData.length);
expect(p.allSelected()).toEqual(someData);
});
test("immutability", () => {
/*
the following should return a new crossfilter:
- addDimension()
- delDimension()
- select
*/
const p2 = payments.addDimension(
"quantity",
"scalar",
(i, data) => data[i].quantity,
Int32Array
);
expect(payments).not.toBe(p2);
const p3 = p2.select("quantity", { mode: "all" });
expect(p3).not.toBe(p2);
const p4 = p3.delDimension("quantity");
expect(p4).not.toBe(p3);
});
test("select all and none", () => {
let p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
.addDimension("total", "scalar", (i, d) => d[i].total, Float32Array)
.addDimension("type", "enum", (i, d) => d[i].type);
expect(p).toBeDefined();
/* expect all records to be selected - default init state */
expect(p.allSelected()).toEqual(someData);
expect(p.countSelected()).toEqual(someData.length);
expect(p.allSelectedMask()).toEqual(
new Uint8Array(someData.length).fill(1)
);
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
new Uint8Array(someData.length).fill(99)
);
for (let i = 0; i < someData.length; i += 1) {
expect(p.isElementSelected(i)).toBeTruthy();
}
/* expect a selectAll on one dimension to change nothing */
p = p.select("tip", { mode: "all" });
expect(p.allSelected()).toEqual(someData);
/* ditto */
p = p.select("quantity", { mode: "all" });
expect(p.allSelected()).toEqual(someData);
/* select none on one dimension */
p = p.select("type", { mode: "none" });
expect(p.allSelected()).toEqual([]);
expect(p.countSelected()).toEqual(0);
expect(p.allSelectedMask()).toEqual(
new Uint8Array(someData.length).fill(0)
);
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
new Uint8Array(someData.length).fill(0)
);
for (let i = 0; i < someData.length; i += 1) {
expect(p.isElementSelected(i)).toBeFalsy();
}
p = p.select("quantity", { mode: "none" });
expect(p.allSelected()).toEqual([]);
// invert the first none; should have no effect because type is
// still not filtered.
p = p.select("quantity", { mode: "all" });
expect(p.allSelected()).toEqual([]);
/* select all of type; should select all records */
p = p.select("type", { mode: "all" });
expect(p.allSelected()).toEqual(someData);
});
describe("scalar dimension", () => {
let p;
beforeEach(() => {
p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
.select("tip", { mode: "all" });
});
/*
select modes: all, none, exact, range
*/
test("all", () => {
expect(p.select("quantity", { mode: "all" }).countSelected()).toEqual(
someData.length
);
});
test("none", () => {
expect(p.select("quantity", { mode: "none" }).countSelected()).toEqual(0);
});
test.each([[[]], [[2]], [[2, 1]], [[9, 82]], [[0, 1]]])("exact: %p", v =>
expect(
p.select("quantity", { mode: "exact", values: v }).countSelected()
).toEqual(_.filter(someData, d => v.includes(d.quantity)).length)
);
test.each([[0, 1], [1, 2], [0, 99], [99, 100000]])("range %p", (lo, hi) =>
expect(
p.select("quantity", { mode: "range", lo, hi }).countSelected()
).toEqual(
_.filter(someData, d => d.quantity >= lo && d.quantity < hi).length
)
);
test("bad mode", () => {
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
});
});
describe("enum dimension", () => {
let p;
beforeEach(() => {
p = payments.addDimension("type", "enum", (i, d) => d[i].type);
});
test("all", () => {
expect(p.select("type", { mode: "all" }).countSelected()).toEqual(
someData.length
);
});
test("none", () => {
expect(p.select("type", { mode: "none" }).countSelected()).toEqual(0);
});
test.each([
[[]],
[["tab"]],
[["visa"]],
[["visa", "tab"]],
[["cash", "tab", "visa"]]
])("exact: %p", v =>
expect(
p.select("type", { mode: "exact", values: v }).countSelected()
).toEqual(_.filter(someData, d => v.includes(d.type)).length)
);
test("range", () => {
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
Error
);
});
test("bad mode", () => {
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
});
});
describe("spatial dimension", () => {
let p;
beforeEach(() => {
const X = someData.map(r => r.coords[0]);
const Y = someData.map(r => r.coords[1]);
p = payments.addDimension("coords", "spatial", X, Y);
});
test("all", () => {
expect(p.select("coords", { mode: "all" }).countSelected()).toEqual(
someData.length
);
});
test("none", () => {
expect(p.select("coords", { mode: "none" }).countSelected()).toEqual(0);
});
test.each([[0, 0, 1, 1], [0, 0, 0.5, 0.5], [0.5, 0.5, 1, 1]])(
"within-rect %d %d %d %d",
(minX, minY, maxX, maxY) => {
expect(
p
.select("coords", { mode: "within-rect", minX, minY, maxX, maxY })
.allSelected()
).toEqual(
_.filter(someData, d => {
const [x, y] = d.coords;
return minX <= x && x < maxX && minY <= y && y < maxY;
})
);
}
);
test.each([
[[[0, 0], [0, 1], [1, 1], [1, 0]]],
[[[0, 0], [0, 0.5], [0.5, 0.5], [0.5, 0]]]
])("within-polygon %p", polygon => {
expect(
p.select("coords", { mode: "within-polygon", polygon }).allSelected()
).toEqual(_.filter(someData, d => polygonContains(polygon, d.coords)));
});
});
describe("non-finite scalars", () => {
let p;
beforeEach(() => {
p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.addDimension(
"nonFinite",
"scalar",
(i, d) => d[i].nonFinite,
Float32Array
)
.select("quantity", { mode: "all" });
});
test("all or none", () => {
expect(p.select("nonFinite", { mode: "all" }).countSelected()).toEqual(
someData.length
);
expect(p.select("nonFinite", { mode: "none" }).countSelected()).toEqual(
0
);
});
test("exact", () => {
expect(
p.select("nonFinite", { mode: "exact", values: [0] }).countSelected()
).toEqual(3);
expect(
p.select("nonFinite", { mode: "exact", values: [1] }).countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.POSITIVE_INFINITY]
})
.countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.NEGATIVE_INFINITY]
})
.countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", { mode: "exact", values: [Number.NaN] })
.countSelected()
).toEqual(4);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.POSITIVE_INFINITY, 0, 1, 99]
})
.countSelected()
).toEqual(6);
});
test("range", () => {
expect(
p
.select("nonFinite", {
mode: "range",
lo: 0,
hi: Number.POSITIVE_INFINITY
})
.countSelected()
).toEqual(5);
expect(
p
.select("nonFinite", {
mode: "range",
lo: 0,
hi: Number.NaN
})
.countSelected()
).toEqual(6);
expect(
p
.select("nonFinite", {
mode: "range",
lo: Number.NEGATIVE_INFINITY,
hi: Number.POSITIVE_INFINITY
})
.countSelected()
).toEqual(7);
});
});
});
@@ -0,0 +1,141 @@
// jshint esversion: 6
// const PositiveIntervals = require("../../src/util/typedCrossfilter/positiveIntervals");
import PositiveIntervals from "../../../src/util/typedCrossfilter/positiveIntervals";
describe("canonicalize", () => {
test("empty", () => {
expect(PositiveIntervals.canonicalize([])).toEqual([]);
});
test("simple, already correct", () => {
expect(PositiveIntervals.canonicalize([[0, 1]])).toEqual([[0, 1]]);
expect(PositiveIntervals.canonicalize([[0, 1], [2, 3]])).toEqual([
[0, 1],
[2, 3]
]);
});
test("non-canonical, need to be canonicalized", () => {
expect(PositiveIntervals.canonicalize([[0, 1], [1, 2]])).toEqual([[0, 2]]);
expect(PositiveIntervals.canonicalize([[1, 2], [2, 3]])).toEqual([[1, 3]]);
});
});
describe("union", () => {
test("empty range", () => {
expect(PositiveIntervals.union([], [])).toEqual([]);
expect(PositiveIntervals.union([], [[1, 2]])).toEqual([[1, 2]]);
expect(PositiveIntervals.union([], [[1, 2], [3, 4]])).toEqual([
[1, 2],
[3, 4]
]);
expect(PositiveIntervals.union([[3, 4]], [])).toEqual([[3, 4]]);
expect(PositiveIntervals.union([[1, 2], [3, 4]], [])).toEqual([
[1, 2],
[3, 4]
]);
expect(PositiveIntervals.union([[3, 3]], [])).toEqual([[3, 3]]);
expect(PositiveIntervals.union([], [[3, 3]])).toEqual([[3, 3]]);
});
test("simple ranges", () => {
expect(PositiveIntervals.union([[1, 2]], [[2, 3]])).toEqual([[1, 3]]);
expect(PositiveIntervals.union([[2, 3]], [[1, 2]])).toEqual([[1, 3]]);
expect(PositiveIntervals.union([[1, 2]], [[3, 4]])).toEqual([
[1, 2],
[3, 4]
]);
expect(
PositiveIntervals.union([[1, 2], [3, 4]], [[6, 7], [19, 40]])
).toEqual([[1, 2], [3, 4], [6, 7], [19, 40]]);
expect(PositiveIntervals.union([[1, 4]], [[1, 1], [3, 4]])).toEqual([
[1, 4]
]);
expect(PositiveIntervals.union([[3, 3]], [[4, 4]])).toEqual([
[3, 3],
[4, 4]
]);
});
});
describe("intersection", () => {
test("empty range", () => {
expect(PositiveIntervals.intersection([], [])).toEqual([]);
expect(PositiveIntervals.intersection([], [[1, 2]])).toEqual([]);
expect(PositiveIntervals.intersection([[1, 2]], [])).toEqual([]);
});
test("simple", () => {
expect(PositiveIntervals.intersection([[1, 2]], [[2, 3]])).toEqual([]);
expect(PositiveIntervals.intersection([[2, 3]], [[1, 2]])).toEqual([]);
expect(PositiveIntervals.intersection([[1, 10]], [[1, 10]])).toEqual([
[1, 10]
]);
expect(PositiveIntervals.intersection([[1, 10]], [[2, 8]])).toEqual([
[2, 8]
]);
expect(PositiveIntervals.intersection([[2, 8]], [[1, 10]])).toEqual([
[2, 8]
]);
expect(PositiveIntervals.intersection([[1, 10]], [[2, 12]])).toEqual([
[2, 10]
]);
expect(PositiveIntervals.intersection([[2, 12]], [[1, 10]])).toEqual([
[2, 10]
]);
expect(PositiveIntervals.intersection([[1, 10]], [[1, 8]])).toEqual([
[1, 8]
]);
expect(PositiveIntervals.intersection([[1, 8]], [[1, 10]])).toEqual([
[1, 8]
]);
expect(PositiveIntervals.intersection([[1, 10]], [[1, 2], [6, 9]])).toEqual(
[[1, 2], [6, 9]]
);
expect(PositiveIntervals.intersection([[0, 2638]], [[1363, 2638]])).toEqual(
[[1363, 2638]]
);
expect(PositiveIntervals.intersection([[1, 2]], [[1, 2]])).toEqual([
[1, 2]
]);
});
});
describe("difference", () => {
test("empty", () => {
expect(PositiveIntervals.difference([], [])).toEqual([]);
expect(PositiveIntervals.difference([], [[1, 10]])).toEqual([]);
expect(PositiveIntervals.difference([[1, 10]], [])).toEqual([[1, 10]]);
});
test("simple", () => {
expect(PositiveIntervals.difference([[1, 2], [3, 4]], [])).toEqual([
[1, 2],
[3, 4]
]);
expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[5, 10]])).toEqual([
[1, 2],
[3, 5]
]);
expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[0, 5]])).toEqual([
[5, 10]
]);
expect(
PositiveIntervals.difference([[0, 2638]], [[0, 1363], [2055, 2638]])
).toEqual([[1363, 2055]]);
expect(
PositiveIntervals.difference([[0, 1363], [2055, 2638]], [[0, 2638]])
).toEqual([]);
expect(PositiveIntervals.difference([[0, 10]], [[0, 1]])).toEqual([
[1, 10]
]);
expect(PositiveIntervals.difference([[0, 10]], [[1, 2]])).toEqual([
[0, 1],
[2, 10]
]);
expect(PositiveIntervals.difference([[0, 10]], [[9, 10]])).toEqual([
[0, 9]
]);
});
});
@@ -0,0 +1,255 @@
import {
sortArray,
sortIndex,
lowerBound,
upperBound,
lowerBoundIndirect,
upperBoundIndirect
} from "../../../src/util/typedCrossfilter/sort";
/*
Sort tests should keep in mind that there are separate code
paths for:
- small vs. large arrays (insertionsort only)
- float-only typed arrays vs. other array types (non-finite handling)
- indexed vs. direct sort
*/
const pInf = Number.POSITIVE_INFINITY;
const nInf = Number.NEGATIVE_INFINITY;
function fillRange(arr, start = 0) {
const larr = arr;
for (let i = 0, len = larr.length; i < len; i += 1) {
larr[i] = i + start;
}
return larr;
}
function fillRand(arr) {
for (let i = 0, len = arr.length; i < len; i += 1) {
arr[i] = Math.random();
}
return arr;
}
describe("sortArray", () => {
describe("JS vals", () => {
[
[true, false],
["a", "b", "0", "1"],
[0, "a", true, null, undefined, 3.1415],
fillRand(new Array(1000)),
["a", NaN, null, pInf]
].map((val, idx) =>
test(`JS vals ${idx}`, () => {
expect(sortArray(val)).toMatchObject(val.sort());
})
);
});
describe("finite numbers", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
Type.from([0, 1, 2, 3, 4, 5, 6])
);
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
Type.from([1, 2, 3, 4, 5, 6])
);
const source = fillRand(new Type(1000));
expect(sortArray(Type.from(source))).toMatchObject(
Type.from(source).sort()
);
})
);
});
describe("non-finite numbers", () => {
test("inifinity", () => {
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
new Float32Array([nInf, 0, 1, 2, pInf])
);
expect(
sortArray(new Float32Array([pInf, nInf, pInf, nInf]))
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf]));
expect(
sortArray(new Float32Array([pInf, nInf, pInf, nInf, pInf]))
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf, pInf]));
expect(
sortArray(
new Float32Array(100).fill(Infinity, 0, 50).fill(-Infinity, 50, 100)
)
).toMatchObject(
new Float32Array(100).fill(-Infinity, 0, 50).fill(Infinity, 50, 100)
);
});
test("NaN", () => {
expect(sortArray(new Float64Array([NaN, 2, 1, 0]))).toMatchObject(
new Float64Array([0, 1, 2, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, 1, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, NaN, 1, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, 1, NaN, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN, NaN])
);
expect(
sortArray(fillRange(new Float32Array(100)).fill(NaN, 0, 10))
).toMatchObject(fillRange(new Float32Array(100), 10).fill(NaN, 90, 100));
});
test("mixed numbers", () => {
expect(
sortArray(new Float32Array([NaN, pInf, nInf, NaN, NaN]))
).toMatchObject(new Float32Array([nInf, pInf, NaN, NaN, NaN]));
expect(
sortArray(new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]))
).toMatchObject(new Float32Array([nInf, 1, 2, pInf, NaN, NaN, NaN]));
expect(
sortArray(new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]))
).toMatchObject(new Float32Array([nInf, 0, 1, 2, pInf, NaN, NaN]));
expect(
sortArray(
fillRange(new Float32Array(100))
.fill(NaN, 0, 10)
.fill(Infinity, 10, 20)
)
).toMatchObject(
fillRange(new Float32Array(100), 20)
.fill(Infinity, 80, 90)
.fill(NaN, 90, 100)
);
});
});
});
describe("sortIndex", () => {
describe("finite numbers", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
const index1 = fillRange(new Uint32Array(source1.length));
expect(sortIndex(index1, source1)).toMatchObject(
index1.sort((a, b) => source1[a] - source1[b])
);
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
const index2 = fillRange(new Uint32Array(source2.length));
expect(sortIndex(index2, source2)).toMatchObject(
index2.sort((a, b) => source1[a] - source1[b])
);
const source3 = fillRand(new Type(1000));
const index3 = fillRange(new Uint32Array(source3.length));
expect(sortIndex(index3, source3)).toMatchObject(
index3.sort((a, b) => source1[a] - source1[b])
);
})
);
});
describe("non-finite numbers", () => {
test("mixed numbers", () => {
const source1 = new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]);
const index1 = fillRange(new Uint32Array(source1.length));
expect(sortIndex(index1, source1)).toMatchObject(
new Uint32Array([2, 4, 6, 1, 0, 3, 5])
);
const source2 = new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]);
const index2 = fillRange(new Uint32Array(source2.length));
expect(sortIndex(index2, source2)).toMatchObject(
new Uint32Array([2, 3, 4, 6, 1, 0, 5])
);
});
});
});
describe("lowerBound", () => {
test("non-float path", () => {
expect(lowerBound([], 0, 0, 0)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], -1, 0, 4)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], 0, 0, 4)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1);
expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3);
expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4);
expect(lowerBound([0, 1, 2, 3, 4], -1, 0, 5)).toEqual(0);
expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0);
expect(lowerBound([0, 1, 2, 3, 4], 2, 0, 5)).toEqual(2);
expect(lowerBound([0, 1, 2, 3, 4], 4, 0, 5)).toEqual(4);
expect(lowerBound([0, 1, 2, 3, 4], 5, 0, 5)).toEqual(5);
expect(lowerBound([0, 2, 4, 6, 8], 5, 0, 5)).toEqual(3);
expect(lowerBound([0, 2, 2, 2, 8], 5, 0, 5)).toEqual(4);
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 3, 2, 4)).toEqual(3);
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 99, 2, 4)).toEqual(4);
});
test("float path, finites", () => {
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
expect(lowerBound(new Float32Array([]), 0, 0, 0)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), -1, 0, 4)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 0, 0, 4)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 3, 0, 4)).toEqual(3);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 4)).toEqual(4);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), -1, 0, 5)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 2, 0, 5)).toEqual(2);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 4, 0, 5)).toEqual(4);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 5, 0, 5)).toEqual(5);
expect(lowerBound(new Float32Array([0, 2, 4, 6, 8]), 5, 0, 5)).toEqual(3);
expect(lowerBound(new Float32Array([0, 2, 2, 2, 8]), 5, 0, 5)).toEqual(4);
expect(
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 3, 2, 4)
).toEqual(3);
expect(
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 99, 2, 4)
).toEqual(4);
});
test("float path, non-finite", () => {
expect(
lowerBound(
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
-Infinity,
0,
5
)
).toEqual(0);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 0, 0, 5)
).toEqual(1);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 1, 0, 5)
).toEqual(2);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 2, 0, 5)
).toEqual(3);
expect(
lowerBound(
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
Infinity,
0,
5
)
).toEqual(3);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), NaN, 0, 5)
).toEqual(4);
});
});
@@ -0,0 +1,112 @@
import {
fillRange,
sliceByIndex,
makeSortIndex
} from "../../../src/util/typedCrossfilter/util";
describe("fillRange", () => {
test("Array", () => {
expect(fillRange(new Array(6))).toMatchObject([0, 1, 2, 3, 4, 5]);
expect(fillRange(new Array(4), 1)).toMatchObject([1, 2, 3, 4]);
expect(fillRange([])).toMatchObject([]);
});
test("Uint32Array", () => {
expect(fillRange(new Uint32Array(6))).toMatchObject(
new Uint32Array([0, 1, 2, 3, 4, 5])
);
expect(fillRange(new Array(4), 1)).toMatchObject(
new Uint32Array([1, 2, 3, 4])
);
expect(fillRange([])).toMatchObject(new Uint32Array([]));
});
});
describe("sliceByIndex", () => {
test("Array", () => {
expect(sliceByIndex([0, 1, 2, 3, 4], [0, 1, 2])).toMatchObject([0, 1, 2]);
expect(sliceByIndex([0, 1, 2, 3, 4], [2, 1, 0])).toMatchObject([2, 1, 0]);
expect(sliceByIndex([0, 1, 2, 3, 4], [])).toMatchObject([]);
expect(sliceByIndex([], [])).toMatchObject([]);
});
test("Uint32Array", () => {
expect(
sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([0, 1, 2]))
).toMatchObject([0, 1, 2]);
expect(
sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([2, 1, 0]))
).toMatchObject([2, 1, 0]);
expect(sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([]))).toMatchObject(
[]
);
expect(sliceByIndex([], new Uint32Array([]))).toMatchObject([]);
expect(
sliceByIndex(new Uint32Array([0, 1, 2, 3, 4]), new Uint32Array([0, 1, 2]))
).toMatchObject(new Uint32Array([0, 1, 2]));
expect(
sliceByIndex(new Uint32Array([0, 1, 2, 3, 4]), new Uint32Array([2, 1, 0]))
).toMatchObject(new Uint32Array([2, 1, 0]));
expect(
sliceByIndex(
new Uint32Array([0, 1, 2, 3, 4]),
new Uint32Array(new Uint32Array([]))
)
).toMatchObject(new Uint32Array([]));
expect(
sliceByIndex(new Uint32Array([]), new Uint32Array([]))
).toMatchObject(new Uint32Array([]));
});
test("Float32Array", () => {
expect(
sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [0, 1, 2])
).toMatchObject(new Float32Array([0, 1, 2]));
expect(
sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [2, 1, 0])
).toMatchObject(new Float32Array([2, 1, 0]));
expect(sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [])).toMatchObject(
new Float32Array([])
);
expect(sliceByIndex(new Float32Array([]), [])).toMatchObject(
new Float32Array([])
);
});
});
describe("makeSortIndex", () => {
test("Array", () => {
expect(makeSortIndex([3, 2, 1, 0])).toMatchObject(
new Uint32Array([3, 2, 1, 0])
);
expect(makeSortIndex([3, 2, 1, 0, 4])).toMatchObject(
new Uint32Array([3, 2, 1, 0, 4])
);
expect(makeSortIndex([])).toMatchObject(new Uint32Array([]));
});
test("Float32Array", () => {
expect(makeSortIndex(new Float32Array([3, 2, 1, 0]))).toMatchObject(
new Uint32Array([3, 2, 1, 0])
);
expect(makeSortIndex(new Float32Array([3, 2, 1, 0, 4]))).toMatchObject(
new Uint32Array([3, 2, 1, 0, 4])
);
expect(makeSortIndex(new Float32Array([]))).toMatchObject(
new Uint32Array([])
);
});
test("Int32Array", () => {
expect(makeSortIndex(new Int32Array([3, 2, 1, 0]))).toMatchObject(
new Uint32Array([3, 2, 1, 0])
);
expect(makeSortIndex(new Int32Array([3, 2, 1, 0, 4]))).toMatchObject(
new Uint32Array([3, 2, 1, 0, 4])
);
expect(makeSortIndex(new Int32Array([]))).toMatchObject(
new Uint32Array([])
);
});
});
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
babelrc: false,
cacheDirectory: true,
presets: [
["modern-browsers", { loose: true, modules: false }],
"@babel/preset-react"
],
plugins: [
"@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-decorators", { legacy: true }],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
};
+17
View File
@@ -0,0 +1,17 @@
module.exports = {
babelrc: false,
presets: [
["modern-browsers", { loose: true, modules: false }],
"@babel/preset-react"
],
plugins: [
"@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-decorators", { legacy: true }],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements",
"@babel/plugin-transform-runtime",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
};
+58
View File
@@ -0,0 +1,58 @@
module.exports = {
root: true,
parser: "babel-eslint",
extends: ["airbnb", "prettier", "prettier/react"],
env: { browser: true, commonjs: true, es6: true },
globals: { expect: true },
parserOptions: {
ecmaVersion: 2017,
sourceType: "module",
ecmaFeatures: {
jsx: true,
generators: true
}
},
rules: {
"no-magic-numbers": "off",
"no-nested-ternary": "off",
"func-style": "off",
"arrow-parens": "off",
"no-use-before-define": "off",
"react/jsx-filename-extension": "off",
"comma-dangle": "off",
"no-underscore-dangle": "off",
quotes: ["error", "double"],
"implicit-arrow-linebreak": "off",
"operator-linebreak": [
"error",
"after",
{ overrides: { "?": "before", ":": "before" } }
],
"no-console": "off",
"spaced-comment": ["error", "always", { exceptions: ["*"] }],
"no-param-reassign": "off",
"object-curly-newline": ["error", { consistent: true }],
"react/prop-types": [0],
"space-before-function-paren": "off",
"function-paren-newline": "off",
"prefer-destructuring": ["error", { object: true, array: false }]
},
overrides: [
{
files: ["**/*.test.js"],
env: {
jest: true // now **/*.test.js files' env has both es6 *and* jest
},
// Can't extend in overrides: https://github.com/eslint/eslint/issues/8813
// "extends": ["plugin:jest/recommended"]
plugins: ["jest"],
rules: {
"jest/no-disabled-tests": "warn",
"jest/no-focused-tests": "error",
"jest/no-identical-title": "error",
"jest/prefer-to-have-length": "warn",
"jest/valid-expect": "error"
}
}
]
};
@@ -1,41 +1,61 @@
// jshint esversion: 6 // jshint esversion: 6
const path = require("path"); const path = require("path");
const autoprefixer = require("autoprefixer");
const webpack = require("webpack"); const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin");
const src = path.resolve("src"); const src = path.resolve("src");
const nodeModules = path.resolve("node_modules"); const nodeModules = path.resolve("node_modules");
const babelOptions = require("../babel/babel.dev");
module.exports = { module.exports = {
mode: "development",
devtool: "eval", devtool: "eval",
entry: [ entry: ["./src/index"],
"webpack-hot-middleware/client?quiet=true&noInfo=true",
require.resolve("react-hot-loader/patch"),
require.resolve("../polyfills/polyfills"),
path.join(src, "index")
],
output: { output: {
path: path.resolve("build"), path: path.resolve("build"),
pathinfo: true, pathinfo: true,
filename: "static/js/bundle.js", filename: "static/js/bundle.js",
publicPath: "/" publicPath: "/"
}, },
resolve: { extensions: [".js", ".json"] },
module: { module: {
loaders: [ rules: [
{ {
test: /\.js$/, test: /\.js$/,
include: src, include: src,
loader: "babel-loader", loader: "babel-loader",
query: require("../babel/babel.dev") options: babelOptions
}, },
{ {
test: /\.css$/, test: /\.css$/,
include: [src, nodeModules], include: src,
loader: exclude: [path.resolve(src, "index.css")],
"style-loader!css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader" loader: [
{
loader: "style-loader"
},
{
loader: "css-loader",
options: {
modules: true,
localIdentName: "[name]__[local]___[hash:base64:5]"
}
}
]
}, },
{
test: /index\.css$/,
include: [path.resolve(src, "index.css")],
loader: [
{
loader: "style-loader"
},
{
loader: "css-loader"
}
]
},
{ test: /\.json$/, include: [src, nodeModules], loader: "json-loader" }, { test: /\.json$/, include: [src, nodeModules], loader: "json-loader" },
{ {
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/, test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
@@ -57,20 +77,9 @@ module.exports = {
template: path.resolve("index.html"), template: path.resolve("index.html"),
favicon: path.resolve("favicon.png") favicon: path.resolve("favicon.png")
}), }),
new webpack.LoaderOptionsPlugin({ new webpack.NoEmitOnErrorsPlugin(),
options: { new webpack.DefinePlugin({
eslint: { __REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })"
configFile: path.resolve("./configuration/eslint/eslint.js"), })
useEslintrc: false
},
postcss() {
return [autoprefixer];
}
}
}),
new webpack.DefinePlugin({ "process.env.NODE_ENV": '"development"' }),
// Note: only CSS is currently hot reloaded
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
] ]
}; };
@@ -1,46 +1,64 @@
// jshint esversion: 6 // jshint esversion: 6
const path = require("path"); const path = require("path");
const autoprefixer = require("autoprefixer");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin");
const ExtractTextPlugin = require("extract-text-webpack-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin"); const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin"); const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
const MinifyPlugin = require("babel-minify-webpack-plugin");
const src = path.resolve("src"); const src = path.resolve("src");
const nodeModules = path.resolve("node_modules"); const nodeModules = path.resolve("node_modules");
const publicPath = "/"; const babelOptions = require("../babel/babel.prod");
const publicPath = "";
module.exports = { module.exports = {
mode: "production",
bail: true, bail: true,
cache: false,
devtool: "cheap-source-map", devtool: "cheap-source-map",
entry: [require.resolve("../polyfills/polyfills"), path.join(src, "index")], entry: ["./src/index.js"],
output: { output: {
path: path.resolve("build"), path: path.resolve("build"),
filename: "static/js/[name].[chunkhash:8].js", filename: "static/js/[name].[chunkhash:8].js",
chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
publicPath publicPath
}, },
resolve: { extensions: [".js", ".json"] },
module: { module: {
loaders: [ rules: [
{ {
test: /\.js$/, test: /\.js$/,
include: src, include: src,
loader: "babel-loader", loader: "babel-loader",
query: require("../babel/babel.prod") options: babelOptions
}, },
{ {
test: /\.css$/, test: /\.css$/,
include: [src, nodeModules], include: src,
loader: ExtractTextPlugin.extract({ exclude: [path.resolve(src, "index.css")],
fallbackLoader: "style-loader", use: [
loader: MiniCssExtractPlugin.loader,
"css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]-autoprefixer!postcss-loader" {
}) loader: "css-loader",
options: {
modules: true,
importLoaders: 1,
localIdentName: "[name]__[local]___[hash:base64:5]"
}
}
]
},
{
test: /index\.css$/,
include: [path.resolve(src, "index.css")],
use: [
MiniCssExtractPlugin.loader,
{
loader: "css-loader",
options: {
importLoaders: 1
}
}
]
}, },
{ {
test: /\.json$/, test: /\.json$/,
@@ -52,7 +70,10 @@ module.exports = {
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/, test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
include: [src, nodeModules], include: [src, nodeModules],
loader: "file-loader", loader: "file-loader",
query: { name: "static/media/[name].[hash:8].[ext]" } options: {
name: "[name].[hash:8].[ext]",
outputPath: "static/media/"
}
}, },
{ {
test: /\.(mp4|webm)(\?.*)?$/, test: /\.(mp4|webm)(\?.*)?$/,
@@ -83,24 +104,16 @@ module.exports = {
} }
}), }),
new HtmlWebpackInlineSourcePlugin(), new HtmlWebpackInlineSourcePlugin(),
new webpack.LoaderOptionsPlugin({ new MiniCssExtractPlugin({
options: { filename: "static/css/[name].[contenthash:8].css"
eslint: {
configFile: path.resolve("./configuration/eslint/eslint.js"),
useEslintrc: false
},
postcss() {
return [autoprefixer];
}
}
}), }),
new webpack.DefinePlugin({ "process.env.NODE_ENV": '"production"' }),
new webpack.optimize.OccurrenceOrderPlugin(),
new MinifyPlugin(),
new ExtractTextPlugin("static/css/[name].[contenthash:8].css"),
new SWPrecacheWebpackPlugin({ new SWPrecacheWebpackPlugin({
cacheId: "cellxgene", cacheId: "cellxgene",
filename: "service-worker.js" filename: "service-worker.js"
}) })
] ],
performance: {
maxEntrypointSize: 2000000,
maxAssetSize: 2000000
}
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+6 -7
View File
@@ -3,12 +3,12 @@
<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>Cellx</title> <title>cellxgene</title>
<link href="https://fonts.googleapis.com/css?family=Roboto: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 { html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
font-family: 'Roboto','Helvetica Neue','Helvetica','Arial',sans-serif; font-family: 'Roboto Condensed','Helvetica Neue','Helvetica','Arial',sans-serif;
font-size: 16px; font-size: 14px;
} }
body { body {
margin: 0; margin: 0;
@@ -24,7 +24,6 @@
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>, please <strong>enable JS</strong> to make this app work.</noscript> <noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>, please <strong>enable JS</strong> to make this app work.</noscript>
<div id="root"></div> <div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.js"></script>
<script src="//d3js.org/d3-scale-chromatic.v0.3.min.js"></script>
</body> </body>
</html> </html>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cellxgene</title>
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
<style>
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
font-family: 'Roboto Condensed', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
font-size: 14px;
}
body {
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
}
</style>
</head>
<body>
<script type="text/javascript">
window.CELLXGENE = {};
window.CELLXGENE.API = {
prefix: window.location.href + "api/",
version: "v0.2/"
};
</script>
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>,
please <strong>enable JS</strong> to make this app work.
</noscript>
<div id="root"></div>
{% for script in SCRIPTS %}
<script type="text/javascript" src="{{script | safe}}"></script>
{% endfor %}
</body>
</html>
+15327
View File
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
{
"name": "cellxgene",
"version": "0.9.2",
"license": "MIT",
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
"repository": "https://github.com/chanzuckerberg/cellxgene",
"scripts": {
"backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ",
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
"clean": "rimraf build",
"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",
"lint": "eslint src",
"smoke-test": "start-server-and-test start-server-for-test :5000 e2e",
"start": "node server/development.js",
"start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad",
"test": "jest",
"unit-test": "jest --testPathIgnorePatterns e2e"
},
"engineStrict": true,
"engines": {
"npm": ">=3.0.0"
},
"eslintConfig": {
"extends": "./configuration/eslint/eslint.js"
},
"eslintIgnore": [
"src/util/stateManager/matrix_generated.js"
],
"resolutions": {
"eslint-scope": "3.7.1"
},
"dependencies": {
"@blueprintjs/core": "^3.15.0",
"@blueprintjs/icons": "^3.3.0",
"@blueprintjs/select": "^3.8.0",
"canvas-fit": "^1.5.0",
"d3": "^4.10.0",
"d3-scale-chromatic": "^1.3.0",
"flatbuffers": "^1.10.2",
"font-color-contrast": "^1.0.3",
"fuzzysort": "^1.1.4",
"gl-mat4": "^1.1.4",
"gl-matrix": "^2.7.1",
"is-number": "^7.0.0",
"key-pressed": "0.0.1",
"lodash": "^4.17.4",
"memoize-one": "^4.0.0",
"mouse-position": "^2.0.1",
"mouse-pressed": "^1.0.0",
"normalize.css": "^8.0.0",
"orbit-camera": "^1.0.0",
"query-string": "^6.1.0",
"react": "^16.6.0",
"react-autocomplete": "^1.7.2",
"react-dom": "^16.6.0",
"react-helmet": "^5.2.0",
"react-icons": "^3.2.2",
"react-redux": "^5.1.0",
"redux": "^4.0.1",
"redux-devtools-extension": "^2.13.5",
"redux-thunk": "^2.2.0",
"regl": "^1.3.9",
"scroll-speed": "^1.0.0",
"urijs": "^1.19.0"
},
"devDependencies": {
"@babel/core": "^7.1.5",
"@babel/plugin-proposal-class-properties": "^7.0.0",
"@babel/plugin-proposal-decorators": "^7.0.0",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
"@babel/plugin-proposal-function-bind": "^7.0.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.2.0",
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
"@babel/plugin-transform-react-constant-elements": "^7.0.0",
"@babel/plugin-transform-runtime": "^7.1.0",
"@babel/preset-env": "^7.1.5",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.0.0",
"@babel/runtime": "^7.1.5",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.0",
"babel-preset-modern-browsers": "^12.0.0",
"chalk": "^2.4.2",
"connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "^1.0.1",
"eslint": "^5.13.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^4.0.0",
"eslint-loader": "^2.1.2",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.16.0",
"eslint-plugin-jest": "^22.2.2",
"eslint-plugin-jsx-a11y": "^6.2.1",
"eslint-plugin-react": "^7.12.4",
"express": "^4.14.0",
"file-loader": "^2.0.0",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0",
"jest": "^24.1.0",
"jest-puppeteer": "^4.1.0",
"json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.4.1",
"puppeteer": "^1.12.1",
"rimraf": "^2.6.3",
"serve-favicon": "^2.3.0",
"start-server-and-test": "^1.7.11",
"style-loader": "^0.23.1",
"sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^1.1.0",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.0",
"webpack-dev-middleware": "^3.1.3"
},
"jest": {
"testMatch": [
"**/__tests__/**/?(*.)(spec|test).js?(x)"
],
"testURL": "http://localhost/",
"setupFiles": [
"./__tests__/setupMissingGlobals.js"
]
},
"babel": {
"env": {
"test": {
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-proposal-function-bind",
"@babel/plugin-proposal-class-properties",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements",
"@babel/plugin-transform-runtime"
]
}
}
}
}
@@ -32,13 +32,11 @@ app.use(historyApiFallback({ verbose: false }));
app.use( app.use(
require("webpack-dev-middleware")(compiler, { require("webpack-dev-middleware")(compiler, {
noInfo: true, logLevel: "warn",
publicPath: config.output.publicPath publicPath: config.output.publicPath
}) })
); );
app.use(require("webpack-hot-middleware")(compiler));
app.use(favicon("./favicon.png")); app.use(favicon("./favicon.png"));
app.get("*", (req, res) => { app.get("*", (req, res) => {
+339
View File
@@ -0,0 +1,339 @@
// jshint esversion: 6
import _ from "lodash";
import * as globals from "../globals";
import { Universe } from "../util/stateManager";
import {
catchErrorsWrap,
doJsonRequest,
doBinaryRequest,
dispatchNetworkErrorMessageToUser
} from "../util/actionHelpers";
/*
Bootstrap application with the initial data loading.
* /config - application configuration
* /schema - schema of dataframe
* /annotations - all metadata annotation
* /layout - all default layout
*/
const doInitialDataLoad = () =>
catchErrorsWrap(async dispatch => {
dispatch({ type: "initial data load start" });
try {
const requestJson = _(["config", "schema"])
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doJsonRequest(url))
.value();
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 */
const config = { ...globals.configDefaults, ...results[0].config };
const [, schema, obsAnno, varAnno, obsLayout] = [...results];
const universe = Universe.createUniverseFromResponse(
config,
schema,
obsAnno,
varAnno,
obsLayout
);
dispatch({
type: "configuration load complete",
config
});
dispatch({
type: "initial data load complete (universe exists)",
universe
});
} catch (error) {
dispatch({ type: "initial data load error", error });
}
}, true);
/*
Set the view (world) to current selection. Placeholder for an async action
which also does re-layout.
*/
const regraph = () => (dispatch, getState) => {
const { universe, world, crossfilter } = getState();
dispatch({
type: "set World to current selection",
universe,
world,
crossfilter
});
};
// Throws
const dispatchExpressionErrors = (dispatch, res) => {
const msg = `Unexpected HTTP response while fetching expression data ${
res.status
}, ${res.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
};
/*
Fetch expression vectors for each gene in genes. This is NOT an action
function, but rather a helper to be called from an action helper that
needs expression data.
Transparently utilizes cached data if it is already present.
*/
async function _doRequestExpressionData(dispatch, getState, genes) {
/* helper for this function only */
const fetchData = async geneNames => {
const res = await fetch(
`${globals.API.prefix}${globals.API.version}data/var`,
{
method: "PUT",
body: JSON.stringify({
filter: {
var: {
annotation_value: [{ name: "name", values: geneNames }]
}
}
}),
headers: new Headers({
accept: "application/octet-stream",
"Content-Type": "application/json"
})
}
);
if (
!res.ok ||
res.headers.get("Content-Type") !== "application/octet-stream"
) {
// WILL throw
return dispatchExpressionErrors(dispatch, res);
}
const data = await res.arrayBuffer();
return Universe.convertDataFBStoObject(universe, data);
};
const state = getState();
const { universe } = state;
/* preload data already in cache */
let expressionData = _.transform(
genes,
(expData, g) => {
const data = universe.varData.col(g);
if (data) {
expData[g] = data.asArray();
}
},
{}
); // --> { gene: data }
/* make a list of genes for which we do not have data */
const genesToFetch = _.filter(genes, g => expressionData[g] === undefined);
dispatch({ type: "expression load start" });
/* Fetch data for any genes not in cache */
if (genesToFetch.length) {
try {
const newExpressionData = await fetchData(genesToFetch);
expressionData = {
...expressionData,
...newExpressionData
};
} catch (error) {
dispatch({ type: "expression load error", error });
throw error; // rethrow
}
}
dispatch({ type: "expression load success", expressionData });
return expressionData;
}
function requestSingleGeneExpressionCountsForColoringPOST(gene) {
return async (dispatch, getState) => {
dispatch({ type: "get single gene expression for coloring started" });
try {
await _doRequestExpressionData(dispatch, getState, [gene]);
const { world } = getState();
dispatch({
type: "color by expression",
gene,
data: {
[gene]: world.varData.col(gene).asArray()
}
});
} catch (error) {
dispatch({
type: "get single gene expression for coloring error",
error
});
}
};
}
const requestUserDefinedGene = gene => async (dispatch, getState) => {
dispatch({ type: "request user defined gene started" });
try {
await await _doRequestExpressionData(dispatch, getState, [gene]);
const { world } = getState();
/* then send the success case action through */
return dispatch({
type: "request user defined gene success",
data: {
genes: [gene],
expression: world.varData.col(gene).asArray()
}
});
} catch (error) {
return dispatch({
type: "request user defined gene error",
error
});
}
};
const dispatchDiffExpErrors = (dispatch, response) => {
switch (response.status) {
case 403:
dispatchNetworkErrorMessageToUser(
"Too many cells selected for differential experesion calculation - please make a smaller selection."
);
break;
case 501:
dispatchNetworkErrorMessageToUser(
"Differential expression is not implemented."
);
break;
default: {
const msg = `Unexpected differential expression HTTP response ${
response.status
}, ${response.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
dispatch({
type: "request differential expression error",
error: new Error(msg)
});
}
}
};
const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
dispatch,
getState
) => {
dispatch({ type: "request differential expression started" });
try {
/*
Steps:
1. get the most differentially expressed genes
2. get expression data for each
*/
const state = getState();
const { universe } = state;
// Legal values are null, Array or TypedArray. Null is initial state.
if (!set1) set1 = [];
if (!set2) set2 = [];
// These lines ensure that we convert any TypedArray to an Array.
// This is necessary because JSON.stringify() does some very strange
// things with TypedArrays (they are marshalled to JSON objects, rather
// than being marshalled as a JSON array).
set1 = Array.isArray(set1) ? set1 : Array.from(set1);
set2 = Array.isArray(set2) ? set2 : Array.from(set2);
const res = await fetch(
`${globals.API.prefix}${globals.API.version}diffexp/obs`,
{
method: "POST",
headers: new Headers({
Accept: "application/json",
"Content-Type": "application/json"
}),
body: JSON.stringify({
mode: "topN",
count: num_genes,
set1: { filter: { obs: { index: set1 } } },
set2: { filter: { obs: { index: set2 } } }
})
}
);
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
return dispatchDiffExpErrors(dispatch, res);
}
const data = await res.json();
// result is [ [varIdx, ...], ... ]
const topNGenes = _.map(data, r =>
universe.varAnnotations.at(r[0], "name")
);
/*
Kick off secondary action to fetch all of the expression data for the
topN expressed genes.
*/
await _doRequestExpressionData(dispatch, getState, topNGenes);
/* then send the success case action through */
return dispatch({
type: "request differential expression success",
data
});
} catch (error) {
return dispatch({
type: "request differential expression error",
error
});
}
};
const resetInterface = () => (dispatch, getState) => {
const { universe } = getState();
dispatch({
type: "user reset start"
});
dispatch({
type: "clear all user defined genes"
});
dispatch({
type: "clear differential expression"
});
dispatch({
type: "reset colorscale"
});
dispatch({
type: "clear scatterplot"
});
dispatch({
type: "reset World to eq Universe",
universe
});
dispatch({
type: "increment graph render counter"
});
dispatch({
type: "user reset end"
});
};
export default {
regraph,
resetInterface,
requestSingleGeneExpressionCountsForColoringPOST,
requestDifferentialExpression,
requestUserDefinedGene,
doInitialDataLoad
};
@@ -1,47 +1,37 @@
// jshint esversion: 6 // jshint esversion: 6
import React from "react"; import React from "react";
import _ from "lodash";
import Helmet from "react-helmet"; import Helmet from "react-helmet";
import Container from "./framework/container";
import { connect } from "react-redux"; import { connect } from "react-redux";
import PulseLoader from "halogen/PulseLoader";
import Container from "./framework/container";
import LeftSideBar from "./leftsidebar"; import LeftSideBar from "./leftsidebar";
import Parallel from "./continuous/parallel"; import Legend from "./continuousLegend";
import Joy from "./joy/joy";
import Graph from "./graph/graph"; import Graph from "./graph/graph";
import * as globals from "../globals";
import actions from "../actions"; import actions from "../actions";
import SectionHeader from "./framework/sectionHeader"; @connect(state => ({
loading: state.controls.loading,
@connect(state => { error: state.controls.error,
return { graphRenderCounter: state.controls.graphRenderCounter
cells: state.cells }))
};
})
class App extends React.Component { class App extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {}; this.state = {};
} }
_onURLChanged() {
this.props.dispatch({ type: "url changed", url: document.location.href });
}
componentDidMount() { componentDidMount() {
const { dispatch } = this.props;
/* listen for url changes, fire one when we start the app up */ /* listen for url changes, fire one when we start the app up */
window.addEventListener("popstate", this._onURLChanged); window.addEventListener("popstate", this._onURLChanged);
this._onURLChanged(); this._onURLChanged();
this.props.dispatch(actions.initialize()); dispatch(actions.doInitialDataLoad(window.location.search));
/*
first request includes query straight off the url bar for now
*/
this.props.dispatch(actions.requestCells(window.location.search));
/* listen for resize events */ /* listen for resize events */
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
this.props.dispatch({ dispatch({
type: "window resize", type: "window resize",
data: { data: {
height: window.innerHeight, height: window.innerHeight,
@@ -49,7 +39,7 @@ class App extends React.Component {
} }
}); });
}); });
this.props.dispatch({ dispatch({
type: "window resize", type: "window resize",
data: { data: {
height: window.innerHeight, height: window.innerHeight,
@@ -58,32 +48,31 @@ class App extends React.Component {
}); });
} }
render() { _onURLChanged() {
// console.log('app:', this.props, this.state) const { dispatch } = this.props;
dispatch({ type: "url changed", url: document.location.href });
}
render() {
const { loading, error, graphRenderCounter } = this.props;
return ( return (
<Container> <Container>
<Helmet title="cellxgene" /> <Helmet title="cellxgene" />
{this.props.cells.loading ? ( {loading ? (
<div <div
style={{ position: "fixed", left: window.innerWidth / 2, top: 150 }} style={{
position: "fixed",
fontWeight: 500,
top: window.innerHeight / 2,
left: window.innerWidth / 2 - 50
}}
> >
<PulseLoader color="rgb(0,0,0)" size="10px" margin="4px" /> loading cellxgene
<span
style={{ fontFamily: globals.accentFont, fontStyle: "italic" }}
>
loading cells
</span>
</div> </div>
) : null} ) : null}
{this.props.cells.error ? "Error loading cells" : null}
{false ? (
<Joy data={this.state.expressions && this.state.expressions.data} />
) : (
""
)}
<div> <div>
<LeftSideBar /> {loading ? null : <LeftSideBar />}
<div <div
style={{ style={{
padding: 15, padding: 15,
@@ -91,8 +80,8 @@ class App extends React.Component {
marginLeft: 350 /* but responsive */ marginLeft: 350 /* but responsive */
}} }}
> >
<Graph /> {loading ? null : <Graph key={graphRenderCounter} />}
{/*<Parallel/>*/} <Legend />
</div> </div>
</div> </div>
</Container> </Container>
@@ -0,0 +1,533 @@
/*
https://bl.ocks.org/mbostock/4341954
https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
*/
// jshint esversion: 6
import React from "react";
import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import * as d3 from "d3";
import memoize from "memoize-one";
import * as globals from "../../globals";
import actions from "../../actions";
import { makeContinuousDimensionName } from "../../util/nameCreators";
@connect(state => ({
world: state.world,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
continuousSelection: state.continuousSelection,
differential: state.differential,
colorAccessor: state.colors.colorAccessor
}))
class HistogramBrush extends React.Component {
static getColumn(world, field, clipped = true) {
/*
Return the underlying Dataframe column for our field. By default,
returns the clipped column. If clipped===false, will return the
unclipped column.
*/
const obsAnnotations = clipped
? world.obsAnnotations
: world.unclipped.obsAnnotations;
const varData = clipped ? world.varData : world.unclipped.varData;
if (obsAnnotations.hasCol(field)) {
return obsAnnotations.col(field);
}
return varData.col(field);
}
calcHistogramCache = memoize((col, field) => {
/*
recalculate expensive stuff, notably bins, summaries, etc.
*/
const histogramCache = {};
const values = col.asArray();
const summary = col.summarize();
const { min: domainMin, max: domainMax } = summary;
histogramCache.x = d3
.scaleLinear()
.domain([domainMin, domainMax])
.range([0, this.width - this.marginRight]);
histogramCache.bins = d3
.histogram()
.domain(histogramCache.x.domain())
.thresholds(40)(values);
const yMax = histogramCache.bins
.map(b => b.length)
.reduce((a, b) => Math.max(a, b));
histogramCache.y = d3
.scaleLinear()
.domain([0, yMax])
.range([this.height - this.marginBottom, 0]);
return histogramCache;
});
constructor(props) {
super(props);
this.width = 340;
this.height = 100;
this.marginBottom = 20; // space for X axis & labels
this.marginRight = 40; // space for Y axis & labels
}
componentDidMount() {
const { field } = this.props;
const { x, y, bins, svgRef } = this._histogram;
this.renderAxesBrushBins(x, y, bins, svgRef, field);
}
componentDidUpdate(prevProps) {
const { field, world, continuousSelection } = this.props;
const { x, y, bins, svgRef } = this._histogram;
let { brushXselection, brushX } = this.state;
let forceBrushUpdate = false;
/*
Update our axis if the underlying dataframe column has changed
*/
const dfColumn = HistogramBrush.getColumn(world, field);
const oldDfColumn = HistogramBrush.getColumn(
prevProps.world,
prevProps.field
);
if (dfColumn !== oldDfColumn) {
({ brushXselection, brushX } = this.renderAxesBrushBins(
x,
y,
bins,
svgRef,
field
));
forceBrushUpdate = true;
}
/*
if the selection has changed, ensure that the brush correctly reflects
the underlying selection.
*/
if (
forceBrushUpdate ||
continuousSelection !== prevProps.continuousSelection
) {
const { isObs, isUserDefined, isDiffExp } = this.props;
const myName = makeContinuousDimensionName(
{ isObs, isUserDefined, isDiffExp },
field
);
const range = continuousSelection[myName];
if (brushXselection) {
const selection = d3.brushSelection(brushXselection.node());
if (!range && selection) {
/* no active selection - clear brush */
brushXselection.call(brushX.move, null);
} else if (range && !selection) {
/* there is an active selection, but no brush - set the brush */
const x0 = x(range[0]);
const x1 = x(range[1]);
brushXselection.call(brushX.move, [x0, x1]);
} else if (range && selection) {
/* there is an active selection and a brush - make sure they match */
const moveDeltaThreshold = 1;
const x0 = x(range[0]);
const x1 = x(range[1]);
const dX0 = Math.abs(x0 - selection[0]);
const dX1 = Math.abs(x1 - selection[1]);
/*
only update the brush if it is grossly incorrect,
as defined by the moveDeltaThreshold
*/
if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) {
brushXselection.call(brushX.move, [x0, x1]);
}
}
}
}
}
onBrush(selection, x, eventType) {
const type = `continuous metadata histogram ${eventType}`;
return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
// ignore programmatically generated events
if (!d3.event.sourceEvent) return;
// ignore cascading events, which are programmatically generated
if (d3.event.sourceEvent.sourceEvent) return;
if (d3.event.selection) {
dispatch({
type,
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
},
range: [x(d3.event.selection[0]), x(d3.event.selection[1])]
});
} else {
dispatch({
type,
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
},
range: null
});
}
};
}
onBrushEnd(selection, x) {
return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
const minAllowedBrushSize = 10;
const smallAmountToAvoidInfiniteLoop = 0.1;
// ignore programmatically generated events
if (!d3.event.sourceEvent) return;
// ignore cascading events, which are programmatically generated
if (d3.event.sourceEvent.sourceEvent) return;
if (d3.event.selection) {
let _range;
if (
d3.event.selection[1] - d3.event.selection[0] >
minAllowedBrushSize
) {
_range = [x(d3.event.selection[0]), x(d3.event.selection[1])];
} else {
/* the user selected range is too small and will be hidden #587, so take control of it procedurally */
/* https://stackoverflow.com/questions/12354729/d3-js-limit-size-of-brush */
const procedurallyResizedBrushWidth =
d3.event.selection[0] +
minAllowedBrushSize +
smallAmountToAvoidInfiniteLoop; //
_range = [x(d3.event.selection[0]), x(procedurallyResizedBrushWidth)];
}
dispatch({
type: "continuous metadata histogram end",
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
},
range: _range
});
} else {
dispatch({
type: "continuous metadata histogram cancel",
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
}
});
}
};
}
drawHistogram(svgRef) {
const { field, world } = this.props;
const col = HistogramBrush.getColumn(world, field);
const histogramCache = this.calcHistogramCache(col, field);
const { x, y, bins } = histogramCache;
this._histogram = { x, y, bins, svgRef };
}
handleColorAction() {
const { dispatch, field, world, ranges } = this.props;
if (world.obsAnnotations.hasCol(field)) {
dispatch({
type: "color by continuous metadata",
colorAccessor: field,
rangeForColorAccessor: ranges
});
} else if (world.varData.hasCol(field)) {
dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field));
}
}
removeHistogram() {
const {
dispatch,
field,
colorAccessor,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
dispatch({
type: "clear user defined gene",
data: field
});
if (field === colorAccessor) {
dispatch({
type: "reset colorscale"
});
}
if (field === scatterplotXXaccessor) {
dispatch({
type: "set scatterplot x",
data: null
});
}
if (field === scatterplotYYaccessor) {
dispatch({
type: "set scatterplot y",
data: null
});
}
}
handleSetGeneAsScatterplotX() {
return () => {
const { dispatch, field } = this.props;
dispatch({
type: "set scatterplot x",
data: field
});
};
}
handleSetGeneAsScatterplotY() {
return () => {
const { dispatch, field } = this.props;
dispatch({
type: "set scatterplot y",
data: field
});
};
}
renderAxesBrushBins(x, y, bins, svgRef, field) {
const svg = d3.select(svgRef);
/* Remove everything */
svg.selectAll("*").remove();
/* BINS */
svg
.insert("g", "*")
.attr("fill", "#bbb")
.selectAll("rect")
.data(bins)
.enter()
.append("rect")
.attr("x", d => x(d.x0) + 1)
.attr("y", d => y(d.length))
.attr("width", d => Math.abs(x(d.x1) - x(d.x0) - 1))
.attr("height", d => y(0) - y(d.length));
/* BRUSH */
const brushX = d3
.brushX()
.extent([[0, 0], [this.width - this.marginRight, this.height]])
/*
emit start so that the Undoable history can save an undo point
upon drag start, and ignore the subsequent intermediate drag events.
*/
.on("start", this.onBrush(field, x.invert, "start").bind(this))
.on("brush", this.onBrush(field, x.invert, "brush").bind(this))
.on("end", this.onBrushEnd(field, x.invert).bind(this));
const brushXselection = d3
.select(svgRef)
.append("g")
.attr("class", "brush")
.attr("data-testid", `${svgRef.dataset.testid}-brush`)
.call(brushX);
/* X AXIS */
svg
.append("g")
.attr("class", "axis axis--x")
.attr("transform", `translate(0,${this.height - this.marginBottom})`)
.call(d3.axisBottom(x).ticks(5));
/* Y AXIS */
svg
.append("g")
.attr("class", "axis axis--y")
.attr("transform", `translate(${this.width - this.marginRight},0)`)
.call(d3.axisRight(y).ticks(3));
/* axis style */
svg.selectAll(".axis text").style("fill", "rgb(80,80,80)");
svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)");
svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)");
const newState = { brushX, brushXselection };
this.setState(newState);
return newState;
}
render() {
const {
field,
world,
colorAccessor,
isUserDefined,
isDiffExp,
logFoldChange,
pvalAdj,
scatterplotXXaccessor,
scatterplotYYaccessor,
zebra
} = this.props;
const fieldForId = field.replace(/\s/g, "_");
const {
min: unclippedRangeMin,
max: unclippedRangeMax
} = HistogramBrush.getColumn(world, field, false).summarize();
const unclippedRangeMinColor =
world.clipQuantiles.min === 0 ? "#bbb" : globals.blue;
const unclippedRangeMaxColor =
world.clipQuantiles.max === 1 ? "#bbb" : globals.blue;
return (
<div
id={`histogram_${fieldForId}`}
data-testid={`histogram-${field}`}
data-testclass={
isDiffExp
? "histogram-diffexp"
: isUserDefined
? "histogram-user-gene"
: "histogram-continuous-metadata"
}
style={{
padding: globals.leftSidebarSectionPadding,
backgroundColor: zebra ? globals.lightestGrey : "white"
}}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
paddingBottom: "8px"
}}
>
{isDiffExp || isUserDefined ? (
<span>
<span
style={{ marginRight: 7 }}
className="bp3-icon-standard bp3-icon-scatter-plot"
/>
<ButtonGroup style={{ marginRight: 7 }}>
<Button
data-testid={`plot-x-${field}`}
onClick={this.handleSetGeneAsScatterplotX(field).bind(this)}
active={scatterplotXXaccessor === field}
intent={scatterplotXXaccessor === field ? "primary" : "none"}
>
plot x
</Button>
<Button
data-testid={`plot-y-${field}`}
onClick={this.handleSetGeneAsScatterplotY(field).bind(this)}
active={scatterplotYYaccessor === field}
intent={scatterplotYYaccessor === field ? "primary" : "none"}
>
plot y
</Button>
</ButtonGroup>
</span>
) : null}
{isUserDefined ? (
<Button
minimal
onClick={this.removeHistogram.bind(this)}
style={{
color: globals.blue,
cursor: "pointer",
marginLeft: 7
}}
>
remove
</Button>
) : null}
<Tooltip content="Use as color scale" position="bottom">
<Button
onClick={this.handleColorAction.bind(this)}
active={colorAccessor === field}
intent={colorAccessor === field ? "primary" : "none"}
data-testclass="colorby"
data-testid={`colorby-${field}`}
icon="tint"
/>
</Tooltip>
</div>
<svg
width={this.width}
height={this.height}
id={`histogram_${fieldForId}_svg`}
data-testclass="histogram-plot"
data-testid={`histogram-${field}-plot`}
ref={svgRef => {
this.drawHistogram(svgRef);
}}
/>
<div
style={{
display: "flex",
justifyContent: "space-between"
}}
>
<span style={{ color: unclippedRangeMinColor }}>
min {unclippedRangeMin.toPrecision(4)}
</span>
<span
data-testclass="brushable-histogram-field-name"
style={{ fontStyle: "italic" }}
>
{field}
</span>
<span style={{ color: unclippedRangeMaxColor }}>
max {unclippedRangeMax.toPrecision(4)}
</span>
</div>
{isDiffExp ? (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "baseline"
}}
>
<span>
<strong>log fold change:</strong>
{` ${logFoldChange.toPrecision(4)}`}
</span>
<span
style={{
marginLeft: 7,
padding: 2
}}
>
<strong>p-value (adj):</strong>
{pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`}
</span>
</div>
) : null}
</div>
);
}
}
export default HistogramBrush;
@@ -0,0 +1,37 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import * as globals from "../../globals";
import Category from "./category";
@connect(state => ({
categoricalSelection: state.categoricalSelection
}))
class Categories extends React.Component {
render() {
const { categoricalSelection } = this.props;
if (!categoricalSelection) return null;
return (
<div
style={{
padding: globals.leftSidebarSectionPadding
}}
>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 4
})}
>
Categorical Metadata
</p>
{_.map(categoricalSelection, (catState, catName) => (
<Category key={catName} metadataField={catName} />
))}
</div>
);
}
}
export default Categories;
@@ -0,0 +1,196 @@
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import { Button, Tooltip } from "@blueprintjs/core";
import * as globals from "../../globals";
import Value from "./value";
import sortedCategoryValues from "./util";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection
}))
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {
isChecked: true,
isExpanded: false
};
}
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField } = this.props;
if (categoricalSelection !== prevProps.categoricalSelection) {
const cat = categoricalSelection[metadataField];
const categoryCount = {
// total number of categories in this dimension
totalCatCount: cat.numCategories,
// number of selected options in this category
selectedCatCount: _.reduce(
cat.categorySelected,
(res, cond) => (cond ? res + 1 : res),
0
)
};
if (categoryCount.selectedCatCount === categoryCount.totalCatCount) {
/* everything is on, so not indeterminate */
this.checkbox.indeterminate = false;
this.setState({ isChecked: true }); // eslint-disable-line react/no-did-update-set-state
} else if (categoryCount.selectedCatCount === 0) {
/* nothing is on, so no */
this.checkbox.indeterminate = false;
this.setState({ isChecked: false }); // eslint-disable-line react/no-did-update-set-state
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) {
/* to be explicit... */
this.checkbox.indeterminate = true;
this.setState({ isChecked: false });
}
}
}
handleColorChange = () => {
const { dispatch, metadataField } = this.props;
dispatch({
type: "color by categorical metadata",
colorAccessor: metadataField
});
};
toggleAll() {
const { dispatch, metadataField } = this.props;
dispatch({
type: "categorical metadata filter all of these",
metadataField
});
this.setState({ isChecked: true });
}
toggleNone() {
const { dispatch, metadataField } = this.props;
dispatch({
type: "categorical metadata filter none of these",
metadataField
});
this.setState({ isChecked: false });
}
handleToggleAllClick() {
const { isChecked } = this.state;
// || this.checkbox.indeterminate === false
if (isChecked) {
this.toggleNone();
} else if (!isChecked) {
this.toggleAll();
}
}
renderCategoryItems() {
const { categoricalSelection, metadataField } = this.props;
const cat = categoricalSelection[metadataField];
const optTuples = sortedCategoryValues([...cat.categoryIndices]);
return _.map(optTuples, (tuple, i) => (
<Value
optTuples={optTuples}
key={tuple[1]}
metadataField={metadataField}
categoryIndex={tuple[1]}
i={i}
/>
));
}
render() {
const { isExpanded, isChecked } = this.state;
const { metadataField, colorAccessor, categoricalSelection } = this.props;
const { isTruncated } = categoricalSelection[metadataField];
return (
<div
style={{
maxWidth: globals.maxControlsWidth
}}
data-testclass="category"
data-testid={`category-${metadataField}`}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline"
}}
>
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "baseline"
}}
>
<label className="bp3-control bp3-checkbox">
<input
data-testclass="category-select"
data-testid={`category-select-${metadataField}`}
onChange={this.handleToggleAllClick.bind(this)}
ref={el => {
this.checkbox = el;
return el;
}}
checked={isChecked}
type="checkbox"
/>
<span className="bp3-control-indicator" />
{""}
</label>
<span
data-testid={`category-expand-${metadataField}`}
style={{
cursor: "pointer",
display: "inline-block"
}}
onClick={() => {
this.setState({ isExpanded: !isExpanded });
}}
>
{metadataField}
{isExpanded ? (
<FaChevronDown
data-testclass="category-expand-is-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
/>
) : (
<FaChevronRight
data-testclass="category-expand-is-not-expanded"
style={{ fontSize: 10, marginLeft: 5 }}
/>
)}
</span>
</div>
<Tooltip content="Use as color scale" position="bottom">
<Button
data-testclass="colorby"
data-testid={`colorby-${metadataField}`}
onClick={this.handleColorChange}
active={colorAccessor === metadataField}
intent={colorAccessor === metadataField ? "primary" : "none"}
icon="tint"
/>
</Tooltip>
</div>
<div style={{ marginLeft: 26 }}>
{isExpanded ? this.renderCategoryItems() : null}
</div>
<div>
{isExpanded && isTruncated ? (
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
) : null}
</div>
</div>
);
}
}
export default Category;
@@ -0,0 +1,71 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import * as d3 from "d3";
@connect()
class Occupancy extends React.Component {
render() {
const {
occupancy,
colorScale,
categoricalSelection,
colorAccessor,
schema
} = this.props;
const width = 100;
const height = 11;
const categories = _.filter(schema.annotations.obs, {
name: colorAccessor
})[0].categories;
const x = d3
.scaleLinear()
/* get all the keys d[1] as an array, then find the sum */
.domain([0, d3.sum(Array.from(occupancy, d => d[1]))])
.range([0, width]);
let currentOffset = 0;
const stacks = categoricalSelection[colorAccessor].categoryValues.map(d => {
const o = occupancy.get(d);
const scaledValue = x(o);
const stackItem = {
key: d,
value: o || 0,
rectWidth: o ? scaledValue : 0,
offset: currentOffset,
fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)"
};
currentOffset += o ? scaledValue : 0;
return stackItem;
});
return (
<svg
style={{
marginRight: 5,
width,
height
}}
>
{stacks.map(d => (
<rect
key={d.key}
width={d.rectWidth}
height={height}
x={d.offset}
title={d.metadataField}
fill={d.fill}
/>
))}
</svg>
);
}
}
export default Occupancy;
+35
View File
@@ -0,0 +1,35 @@
// jshint esversion: 6
// values is [ [optVal, optIdx], ...]
// index is range array
// return sorted index
import isNumber from "is-number";
import _ from "lodash";
const sortedCategoryValues = values => {
/* this sort could be memoized for perf */
const strings = [];
const ints = [];
_.forEach(values, v => {
if (isNumber(v[0])) {
ints.push(v);
} else {
strings.push(v);
}
});
strings.sort((a, b) => {
const textA = String(a[0]).toUpperCase();
const textB = String(b[0]).toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
ints.sort((a, b) => +a[0] - +b[0]);
return ints.concat(strings);
};
export default sortedCategoryValues;
+151
View File
@@ -0,0 +1,151 @@
// jshint esversion: 6
import { connect } from "react-redux";
import React from "react";
import _ from "lodash";
import Occupancy from "./occupancy";
import { countCategoryValues2D } from "../../util/stateManager/worldUtil";
import * as globals from "../../globals";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
schema: _.get(state.world, "schema", null),
world: state.world
}))
class CategoryValue extends React.Component {
toggleOff() {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "categorical metadata filter deselect",
metadataField,
categoryIndex
});
}
toggleOn() {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
type: "categorical metadata filter select",
metadataField,
categoryIndex
});
}
render() {
const {
categoricalSelection,
metadataField,
categoryIndex,
colorAccessor,
colorScale,
i,
schema,
world
} = this.props;
if (!categoricalSelection) return null;
const category = categoricalSelection[metadataField];
const selected = category.categorySelected[categoryIndex];
const count = category.categoryCounts[categoryIndex];
const value = category.categoryValues[categoryIndex];
const displayString = String(
category.categoryValues[categoryIndex]
).valueOf();
/* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor;
let categories = null;
let occupancy = null;
if (isColorBy && schema) {
categories = _.filter(schema.annotations.obs, {
name: colorAccessor
})[0].categories;
}
if (colorAccessor && !isColorBy && categoricalSelection[colorAccessor]) {
occupancy = countCategoryValues2D(
metadataField,
colorAccessor,
world.obsAnnotations
);
}
return (
<div
key={i}
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between"
}}
data-testclass="categorical-row"
>
<div
style={{
margin: 0,
padding: 0,
userSelect: "none",
width: globals.leftSidebarWidth - 130,
display: "flex",
justifyContent: "space-between"
}}
>
<label className="bp3-control bp3-checkbox">
<input
onChange={
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)
}
data-testclass="categorical-value-select"
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
checked={selected}
type="checkbox"
/>
<span className="bp3-control-indicator" />
<span
data-testid={`categorical-value-${metadataField}-${displayString}`}
data-testclass="categorical-value"
>
{displayString}
</span>
</label>
<span style={{ flexShrink: 0 }}>
{colorAccessor &&
!isColorBy &&
categoricalSelection[colorAccessor] ? (
<Occupancy
occupancy={occupancy.get(
category.categoryValues[categoryIndex]
)}
{...this.props}
/>
) : null}
</span>
</div>
<span>
<span
data-testclass="categorical-value-count"
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
>
{count}
</span>
<svg
style={{
marginLeft: 5,
width: 11,
height: 11,
backgroundColor:
isColorBy && categories
? colorScale(categories.indexOf(value))
: "inherit"
}}
/>
</span>
</div>
);
}
}
export default CategoryValue;
@@ -0,0 +1,95 @@
// jshint esversion: 6
/* rc slider https://www.npmjs.com/package/rc-slider */
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import * as globals from "../../globals";
import HistogramBrush from "../brushableHistogram";
@connect(state => ({
obsAnnotations: _.get(state.world, "obsAnnotations", null),
colorAccessor: state.colors.colorAccessor,
colorScale: state.colors.scale,
schema: _.get(state.world, "schema", null)
}))
class Continuous extends React.Component {
constructor(props) {
super(props);
this.hasContinuous = false;
this.continuousChecked = false;
this.state = {};
}
componentDidUpdate() {}
handleColorAction(key) {
return () => {
const { dispatch, obsAnnotations } = this.props;
const summary = obsAnnotations.col(key).summarize();
dispatch({
type: "color by continuous metadata",
colorAccessor: key,
rangeForColorAccessor: summary
});
};
}
render() {
const { obsAnnotations, schema } = this.props;
if (schema && !this.continuousChecked) {
this.hasContinuous = _.some(
schema.annotations.obs,
d => d.type === "int32" || d.type === "float32"
);
this.continuousChecked = true; /* only do this once */
}
/* initial value for iterator to simulate index, ranges is an object */
let zebra = -1;
return (
<div>
{this.hasContinuous ? (
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 40,
paddingLeft: globals.leftSidebarSectionPadding
})}
>
Continuous metadata
</p>
) : null}
{obsAnnotations
? _.map(obsAnnotations.colIndex.keys(), key => {
const isColorField =
key.includes("color") || key.includes("Color");
if (key === "name" || isColorField) return null;
const summary = obsAnnotations.col(key).summarize();
const nonFiniteExtent =
summary.min === undefined || summary.max === undefined;
if (!summary.categorical && !nonFiniteExtent) {
zebra += 1;
return (
<HistogramBrush
key={key}
field={key}
isObs
zebra={zebra % 2 === 0}
ranges={summary}
handleColorAction={this.handleColorAction(key).bind(this)}
/>
);
}
return null;
})
: null}
</div>
);
}
}
export default Continuous;
@@ -1,4 +1,5 @@
// jshint esversion: 6 // jshint esversion: 6
import * as d3 from "d3";
import styles from "./parallelCoordinates.css"; import styles from "./parallelCoordinates.css";
import { yAxis, brushstart } from "./util"; import { yAxis, brushstart } from "./util";
@@ -19,13 +20,11 @@ const drawAxes = (
******************************************/ ******************************************/
function brush() { function brush() {
var actives = []; const actives = [];
svg svg
.selectAll(".parcoords_axis .parcoords_brush") .selectAll(".parcoords_axis .parcoords_brush")
.filter(function(d) { .filter(() => d3.brushSelection(this))
return d3.brushSelection(this); .each(d => {
})
.each(function(d) {
actives.push({ actives.push({
dimension: d, dimension: d,
extent: d3.brushSelection(this) extent: d3.brushSelection(this)
@@ -35,20 +34,18 @@ const drawAxes = (
handleBrushAction(actives); handleBrushAction(actives);
} }
var axes = svg const axes = svg
.selectAll(".parcoords_axis") .selectAll(".parcoords_axis")
.data(dimensions) .data(dimensions)
.enter() .enter()
.append("g") .append("g")
.attr("class", `${styles.axis} parcoords_axis`) .attr("class", `${styles.axis} parcoords_axis`)
.attr("transform", (d, i) => { .attr("transform", (d, i) => `translate(${xscale(i)})`);
return "translate(" + xscale(i) + ")";
});
axes axes
.append("g") .append("g")
.each(function(d) { .each(d => {
var renderAxis = const renderAxis =
"axis" in d "axis" in d
? d.axis.scale(d.scale) // custom axis ? d.axis.scale(d.scale) // custom axis
: yAxis.scale(d.scale); // default axis : yAxis.scale(d.scale); // default axis
@@ -60,15 +57,13 @@ const drawAxes = (
}) })
.attr("class", styles.title) .attr("class", styles.title)
.attr("text-anchor", "start") .attr("text-anchor", "start")
.text(function(d) { .text(d => ("description" in d ? `${d.description} 🖌️` : `${d.key} 🖌️`));
return "description" in d ? d.description + " 🖌️" : d.key + " 🖌️";
});
// Add and store a brush for each axis. // Add and store a brush for each axis.
axes axes
.append("g") .append("g")
.attr("class", `${styles.brush} parcoords_brush`) .attr("class", `${styles.brush} parcoords_brush`)
.each(function(d) { .each(d => {
d3.select(this).call( d3.select(this).call(
(d.brush = d3 (d.brush = d3
.brushY() .brushY()
@@ -7,24 +7,24 @@ body {
} }
*/ */
.parcoords { :local(.parcoords) {
display: block; display: block;
} }
.parcoords svg, :local(.parcoords) svg,
.parcoords canvas { :local(.parcoords) canvas {
font: 10px sans-serif; font: 10px sans-serif;
position: absolute; position: absolute;
} }
.parcoords canvas { :local(.parcoords) canvas {
opacity: 0.9; opacity: 0.9;
pointer-events: none; pointer-events: none;
} }
.axis .title { :local(.axis .title) {
font-size: 10px; font-size: 10px;
transform: rotate(-21deg) translate(-5px,-6px); transform: rotate(-21deg) translate(-5px, -6px);
fill: #222; fill: #222;
cursor: pointer; cursor: pointer;
} }
@@ -32,14 +32,14 @@ body {
/* -webkit-filter: grayscale(100%); /* -webkit-filter: grayscale(100%);
filter: grayscale(100%); */ filter: grayscale(100%); */
.axis line, :local(.axis) line,
.axis path { :local(.axis) path {
fill: none; fill: none;
stroke: #ccc; stroke: #ccc;
stroke-width: 1px; stroke-width: 1px;
} }
.axis .tick text { :local(.axis .tick) text {
fill: #222; fill: #222;
pointer-events: none; pointer-events: none;
} }
@@ -52,39 +52,39 @@ old, from reference bl.ocks
} }
*/ */
.axis:hover line, :local(.axis:hover) line,
.axis:hover path, :local(.axis:hover) path,
.axis.active line, :local(.axis.active) line,
.axis.active path { :local(.axis.active) path {
fill: none; fill: none;
stroke: #222; stroke: #222;
stroke-width: 1px; stroke-width: 1px;
} }
.axis:hover .title { :local(.axis:hover .title) {
font-weight: bold; font-weight: bold;
} }
.axis:hover .tick text { :local(.axis:hover .tick) text {
opacity: 1; opacity: 1;
} }
.axis.active .title { :local(.axis.active .title) {
font-weight: bold; font-weight: bold;
} }
.axis.active .tick text { :local(.axis.active .tick) text {
opacity: 1; opacity: 1;
font-weight: bold; font-weight: bold;
} }
.brush .extent { :local(.brush .extent) {
fill-opacity: .3; fill-opacity: 0.3;
stroke: #fff; stroke: #fff;
stroke-width: 1px; stroke-width: 1px;
} }
.pre { :local(.pre) {
width: 100%; width: 100%;
height: 300px; height: 300px;
margin: 6px 12px; margin: 6px 12px;
@@ -4,26 +4,28 @@
****************************************** ******************************************
******************************************/ ******************************************/
// jshint esversion: 6 // jshint esversion: 6
const setupParallelCoordinates = (width, height, margin) => { import * as d3 from "d3";
var container = d3.select("#parcoords");
var svg = container const setupParallelCoordinates = (width, height, margin) => {
const container = d3.select("#parcoords");
const svg = container
.append("svg") .append("svg")
.attr("width", width + margin.left + margin.right) .attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom) .attr("height", height + margin.top + margin.bottom)
.append("g") .append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); .attr("transform", `translate(${margin.left},${margin.top})`);
var canvas = container const canvas = container
.append("canvas") .append("canvas")
.attr("width", width * devicePixelRatio) .attr("width", width * devicePixelRatio)
.attr("height", height * devicePixelRatio) .attr("height", height * devicePixelRatio)
.style("width", width + "px") .style("width", `${width}px`)
.style("height", height + "px") .style("height", `${height}px`)
.style("margin-top", margin.top + "px") .style("margin-top", `${margin.top}px`)
.style("margin-left", margin.left + "px"); .style("margin-left", `${margin.left}px`);
var ctx = canvas.node().getContext("2d"); const ctx = canvas.node().getContext("2d");
ctx.globalCompositeOperation = "darken"; ctx.globalCompositeOperation = "darken";
ctx.globalAlpha = 0.15; ctx.globalAlpha = 0.15;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
@@ -1,5 +1,6 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import * as d3 from "d3";
const paddingRight = 120; const paddingRight = 120;
const continuousChartWidth = 1200; const continuousChartWidth = 1200;
@@ -17,11 +18,10 @@ export const createDimensions = data => {
_.each(data, (value, key) => { _.each(data, (value, key) => {
if (value.range) { if (value.range) {
newArr.push({ newArr.push({
key: key /* room for confusion: lodash calls this key, it's also the name of the property parallel coords code is looking for */, key /* room for confusion: lodash calls this key, it's also the name of the property parallel coords code is looking for */,
type: { type: {
within: (d, extent, dim) => { within: (d, extent, dim) =>
return extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1]; extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1]
}
}, },
scale: d3 scale: d3
.scaleSqrt() .scaleSqrt()
@@ -39,19 +39,12 @@ export const brushstart = () => {
d3.event.sourceEvent.stopPropagation(); d3.event.sourceEvent.stopPropagation();
}; };
export const d3_functor = v => { // Unused.
return typeof v === "function" // export const d3_functor = v => (typeof v === "function" ? v : () => v);
? v export const project = (d, dimensions, xscale) =>
: () => { dimensions.map((p, i) => {
return v;
};
};
export const project = (d, dimensions, xscale) => {
return dimensions.map((p, i) => {
// check if data element has property and contains a value // check if data element has property and contains a value
if (!(p.key in d) || d[p.key] === null) return null; if (!(p.key in d) || d[p.key] === null) return null;
return [xscale(i), p.scale(d[p.key])]; return [xscale(i), p.scale(d[p.key])];
}); });
};
@@ -0,0 +1,153 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import * as d3 from "d3";
import { interpolateViridis, interpolateCool } from "d3-scale-chromatic";
// create continuous color legend
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
const continuous = (selectorId, colorscale, colorAccessor) => {
const legendheight = 200;
const legendwidth = 80;
const margin = { top: 10, right: 60, bottom: 10, left: 2 };
const canvas = d3
.select(selectorId)
.style("height", `${legendheight}px`)
.style("width", `${legendwidth}px`)
.append("canvas")
.attr("height", legendheight - margin.top - margin.bottom)
.attr("width", 1)
.style("height", `${legendheight - margin.top - margin.bottom}px`)
.style("width", `${legendwidth - margin.left - margin.right}px`)
.style("position", "absolute")
.style("top", `${margin.top + 1}px`)
.style("left", `${margin.left + 1}px`)
.style(
"transform",
"scale(1,-1)"
) /* flip it! dark is high value light is low.
we flip the color scale as well [1, 0] instead of [0, 1] */
.node();
const ctx = canvas.getContext("2d");
const legendscale = d3
.scaleLinear()
.range([1, legendheight - margin.top - margin.bottom])
.domain([
colorscale.domain()[1],
colorscale.domain()[0]
]); /* we flip this to make viridis colors dark if high in the color scale */
// image data hackery based on http://bl.ocks.org/mbostock/048d21cf747371b11884f75ad896e5a5
const image = ctx.createImageData(1, legendheight);
d3.range(legendheight).forEach(i => {
const c = d3.rgb(colorscale(legendscale.invert(i)));
image.data[4 * i] = c.r;
image.data[4 * i + 1] = c.g;
image.data[4 * i + 2] = c.b;
image.data[4 * i + 3] = 255;
});
ctx.putImageData(image, 0, 0);
// A simpler way to do the above, but possibly slower. keep in mind the legend
// width is stretched because the width attr of the canvas is 1
// See http://stackoverflow.com/questions/4899799/whats-the-best-way-to-set-a-single-pixel-in-an-html5-canvas
/*
d3.range(legendheight).forEach(function(i) {
ctx.fillStyle = colorscale(legendscale.invert(i));
ctx.fillRect(0,i,1,1);
});
*/
const legendaxis = d3
.axisRight()
.scale(legendscale)
.tickSize(6)
.ticks(8);
const svg = d3
.select(selectorId)
.append("svg")
.attr("height", `${legendheight}px`)
.attr("width", `${legendwidth}px`)
.style("position", "absolute")
.style("left", "0px")
.style("top", "0px");
svg
.append("g")
.attr("class", "axis")
.attr(
"transform",
`translate(${legendwidth - margin.left - margin.right + 3},${margin.top})`
)
.call(legendaxis);
// text label for the y axis
svg
.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 2)
.attr("x", 0 - legendheight / 2)
.attr("dy", "1em")
.style("text-anchor", "middle")
.style("fill", "white")
.text(colorAccessor);
};
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
colorScale: state.colors.scale,
responsive: state.responsive
}))
class ContinuousLegend extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
componentDidUpdate(prevProps) {
const { colorAccessor, responsive, colorScale } = this.props;
if (
prevProps.colorAccessor !== colorAccessor ||
prevProps.colorScale !== colorScale ||
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
) {
/* always remove it, if it's not continuous we don't put it back. */
d3.select("#continuous_legend")
.selectAll("*")
.remove();
}
if (colorAccessor && colorScale && colorScale.range) {
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
if (colorScale.range()[0][0] !== "#") {
continuous(
"#continuous_legend",
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
colorAccessor
);
}
}
}
render() {
const { colorAccessor, responsive } = this.props;
return (
<div
id="continuous_legend"
style={{
position: "fixed",
display: colorAccessor ? "inherit" : "none",
right: 0,
top: responsive.height / 2
}}
/>
);
}
}
export default ContinuousLegend;
@@ -1,4 +1,4 @@
.btn { :local(.btn) {
background: none; background: none;
cursor: pointer; cursor: pointer;
color: "#FFF"; color: "#FFF";
@@ -1,3 +1,3 @@
.container { :local(.container) {
min-height: 100%; min-height: 100%;
} }
@@ -0,0 +1,11 @@
// jshint esversion: 6
import React from "react";
import styles from "./container.css";
const Container = props => {
const { children } = props;
return <div className={styles.container}>{children}</div>;
};
export default Container;
@@ -0,0 +1,31 @@
import { Position, Toaster, Intent } from "@blueprintjs/core";
/** Singleton toaster instance. Create separate instances for different options. */
const ErrorToastTopCenter = Toaster.create({
className: "recipe-toaster",
position: Position.TOP
});
/*
A "user" error - eg, bad input
*/
export const postUserErrorToast = message =>
ErrorToastTopCenter.show({ message, intent: Intent.WARNING });
/*
A toast the user must dismiss manually, because they need to act on its information,
ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix.
*/
export const keepAroundErrorToast = message =>
ErrorToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING });
/*
a hard network error
*/
export const postNetworkErrorToast = message =>
ErrorToastTopCenter.show({
message,
timeout: 30000,
intent: Intent.DANGER
});
@@ -0,0 +1,62 @@
// jshint esversion: 6
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import { World } from "../../util/stateManager";
@connect()
class CellSetButton extends React.Component {
set() {
const {
differential,
crossfilter,
dispatch,
eitherCellSetOneOrTwo
} = this.props;
// Reducer and components assume that value will be null if
// no selection made. World..getSelectedByIndex() returns a
// zero length TypedArray when nothing is selected.
let set = World.getSelectedByIndex(crossfilter);
if (set.length === 0) set = null;
if (!differential.diffExp) {
/* diffexp needs to be cleared before we store a new set */
dispatch({
type: `store current cell selection as differential set ${eitherCellSetOneOrTwo}`,
data: set
});
}
}
render() {
const { differential, eitherCellSetOneOrTwo } = this.props;
const cellListName = `celllist${eitherCellSetOneOrTwo}`;
let cells_selected = differential[cellListName]
? differential[cellListName].length
: 0;
return (
<Tooltip
content="Save current selection for differential expression computation"
position="top"
>
<AnchorButton
style={{ marginRight: 10 }}
type="button"
disabled={differential.diffExp}
onClick={this.set.bind(this)}
data-testid={`cellset-button-${eitherCellSetOneOrTwo}`}
>
{eitherCellSetOneOrTwo}
{": "}
<span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}>
{cells_selected}
</span>
{" cells"}
</AnchorButton>
</Tooltip>
);
}
}
export default CellSetButton;
@@ -0,0 +1,101 @@
// 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;
@@ -0,0 +1,351 @@
// jshint esversion: 6
/* rc slider https://www.npmjs.com/package/rc-slider */
import React from "react";
import _ from "lodash";
import fuzzysort from "fuzzysort";
import { connect } from "react-redux";
import {
MenuItem,
Button,
FormGroup,
InputGroup,
ControlGroup
} from "@blueprintjs/core";
import { Suggest } from "@blueprintjs/select";
import HistogramBrush from "../brushableHistogram";
import * as globals from "../../globals";
import actions from "../../actions";
import {
postUserErrorToast,
keepAroundErrorToast
} from "../framework/toasters";
import ExpressionButtons from "./expressionButtons";
const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
if (!modifiers.matchesPredicate) {
return null;
}
/* the fuzzysort wraps the object with other properties, like a score */
const geneName = fuzzySortResult.target;
return (
<MenuItem
active={modifiers.active}
disabled={modifiers.disabled}
data-testid={`suggest-menu-item-${geneName}`}
// Use of annotations in this way is incorrect and dataset specific.
// See https://github.com/chanzuckerberg/cellxgene/issues/483
// label={gene.n_counts}
key={geneName}
onClick={g =>
/* this fires when user clicks a menu item */
handleClick(g)
}
text={geneName}
/>
);
};
const filterGenes = (query, genes) =>
/* fires on load, once, and then for each character typed into the input */
fuzzysort.go(query, genes, {
limit: 5,
threshold: -10000 // don't return bad results
});
@connect(state => {
return {
obsAnnotations: _.get(state.world, "obsAnnotations", null),
userDefinedGenes: state.controls.userDefinedGenes,
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
world: state.world,
colorAccessor: state.colors.colorAccessor,
differential: state.differential
};
})
class GeneExpression extends React.Component {
constructor(props) {
super(props);
this.state = {
bulkAdd: "",
tab: "autosuggest"
};
}
placeholderGeneNames() {
/*
return a string containing gene name suggestions for use as a user hint.
Eg., Apod, Cd74, ...
Will return a max of 3 genes, totalling 15 characters in length.
Randomly selects gene names.
NOTE: the random selection means it will re-render constantly.
*/
const { world } = this.props;
const { varAnnotations } = world;
const geneNames = varAnnotations.col("name").asArray();
if (geneNames.length > 0) {
const placeholder = [];
let len = geneNames.length;
const maxGeneNameCount = 3;
const maxStrLength = 15;
len = len < maxGeneNameCount ? len : maxGeneNameCount;
for (let i = 0, strLen = 0; i < len && strLen < maxStrLength; i += 1) {
const deal = Math.floor(Math.random() * geneNames.length);
const geneName = geneNames[deal];
placeholder.push(geneName);
strLen += geneName.length + 2; // '2' is the length of a comma and space
}
placeholder.push("...");
return placeholder.join(", ");
}
// default - should never happen.
return "Apod, Cd74, ...";
}
handleClick(g) {
const { world, dispatch, userDefinedGenes } = this.props;
const gene = g.target;
if (userDefinedGenes.indexOf(gene) !== -1) {
postUserErrorToast("That gene already exists");
} else if (userDefinedGenes.length > 15) {
postUserErrorToast(
"That's too many genes, you can have at most 15 user defined genes"
);
} else if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
postUserErrorToast("That doesn't appear to be a valid gene name.");
} else {
dispatch({ type: "single user defined gene start" });
dispatch(actions.requestUserDefinedGene(gene)).then(
() => dispatch({ type: "single user defined gene complete" }),
() => dispatch({ type: "single user defined gene error" })
);
}
}
handleBulkAddClick() {
const { world, dispatch, userDefinedGenes } = this.props;
const { bulkAdd } = this.state;
/*
test:
Apod,,, Cd74,, ,,, Foo, Bar-2,,
*/
if (bulkAdd !== "") {
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
dispatch({ type: "bulk user defined gene start" });
Promise.all(
genes.map(gene => {
if (gene.length === 0) {
return keepAroundErrorToast("Must enter a gene name.");
}
if (userDefinedGenes.indexOf(gene) !== -1) {
return keepAroundErrorToast("That gene already exists");
}
if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
return keepAroundErrorToast(
`${gene} doesn't appear to be a valid gene name.`
);
}
return dispatch(actions.requestUserDefinedGene(gene));
})
).then(
() => dispatch({ type: "bulk user defined gene complete" }),
() => dispatch({ type: "bulk user defined gene error" })
);
}
this.setState({ bulkAdd: "" });
}
render() {
const {
world,
userDefinedGenes,
userDefinedGenesLoading,
differential
} = this.props;
const { tab, bulkAdd } = this.state;
return (
<div>
<div
style={{
marginTop: 30
}}
>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
paddingLeft: globals.leftSidebarSectionPadding,
margin: 0
})}
>
Selected Genes
</p>
<div
style={{
padding: globals.leftSidebarSectionPadding
}}
>
<Button
active={tab === "autosuggest"}
style={{ marginRight: 5 }}
minimal
small
data-testid="tab-autosuggest"
onClick={() => {
this.setState({ tab: "autosuggest" });
}}
>
Autosuggest
</Button>
<Button
active={tab === "bulkadd"}
minimal
small
data-testid="section-bulk-add"
onClick={() => {
this.setState({ tab: "bulkadd" });
}}
>
Bulk add genes
</Button>
</div>
{tab === "autosuggest" ? (
<ControlGroup
style={{
paddingLeft: globals.leftSidebarSectionPadding,
paddingBottom: globals.leftSidebarSectionPadding
}}
>
<Suggest
resetOnSelect
closeOnSelect
resetOnClose
itemDisabled={
userDefinedGenesLoading ? () => true : () => false
}
noResults={<MenuItem disabled text="No matching genes." />}
onItemSelect={g => {
/* this happens on 'enter' */
this.handleClick(g);
}}
inputProps={{ "data-testid": "gene-search" }}
inputValueRenderer={g => {
return "";
}}
itemListPredicate={filterGenes}
itemRenderer={renderGene.bind(this)}
items={
world && world.varAnnotations
? world.varAnnotations.col("name").asArray()
: ["No genes"]
}
popoverProps={{ minimal: true }}
/>
<Button
className="bp3-button bp3-intent-primary"
data-testid={"add-gene"}
loading={userDefinedGenesLoading}
>
Add
</Button>
</ControlGroup>
) : null}
{tab === "bulkadd" ? (
<div style={{ paddingLeft: globals.leftSidebarSectionPadding }}>
<form
onSubmit={e => {
e.preventDefault();
this.handleBulkAddClick();
}}
>
<FormGroup
helperText="Add a list of genes (comma delimited)"
labelFor="text-input-bulk-add"
>
<ControlGroup>
<InputGroup
onChange={e => {
this.setState({ bulkAdd: e.target.value });
}}
id="text-input-bulk-add"
data-testid="input-bulk-add"
placeholder={this.placeholderGeneNames()}
value={bulkAdd}
/>
<Button
intent="primary"
onClick={this.handleBulkAddClick.bind(this)}
loading={userDefinedGenesLoading}
>
Add
</Button>
</ControlGroup>
</FormGroup>
</form>
</div>
) : null}
{world && userDefinedGenes.length > 0
? _.map(userDefinedGenes, (geneName, index) => {
const values = world.varData.col(geneName);
if (!values) {
return null;
}
const summary = values.summarize();
return (
<HistogramBrush
key={geneName}
field={geneName}
zebra={index % 2 === 0}
ranges={summary}
isUserDefined
/>
);
})
: null}
</div>
<div>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 40,
paddingLeft: globals.leftSidebarSectionPadding
})}
>
Differentially Expressed Genes
</p>
<ExpressionButtons />
{differential.diffExp
? _.map(differential.diffExp, (value, index) => {
const name = world.varAnnotations.at(value[0], "name");
const values = world.varData.col(name);
if (!values) {
return null;
}
const summary = values.summarize();
return (
<HistogramBrush
key={name}
field={name}
zebra={index % 2 === 0}
ranges={summary}
isDiffExp
logFoldChange={value[1]}
pval={value[2]}
pvalAdj={value[3]}
/>
);
})
: null}
</div>
</div>
);
}
}
export default GeneExpression;
@@ -38,15 +38,7 @@ export default function(regl) {
uniforms: { uniforms: {
distance: regl.prop("distance"), distance: regl.prop("distance"),
view: regl.prop("view"), view: regl.prop("view"),
projection: (context, props) => { projection: ({viewportWidth, viewportHeight}) => mat4.perspective([], Math.PI / 2, viewportWidth / viewportHeight, 0.01, 1000)
return mat4.perspective(
[],
Math.PI / 2,
context.viewportWidth * props.scale / context.viewportHeight,
0.01,
1000
);
}
}, },
count: regl.prop("count"), count: regl.prop("count"),
@@ -1,4 +1,4 @@
.graphCanvas path { :local(.graphCanvas) path {
shape-rendering: crispEdges; shape-rendering: crispEdges;
} }
@@ -16,8 +16,8 @@
pointer-events: none; pointer-events: none;
} }
.axis path, :local(.axis) path,
.axis line { :local(.axis) line {
fill: none; fill: none;
stroke: #000; stroke: #000;
stroke-width: 1px; stroke-width: 1px;
@@ -29,6 +29,6 @@ circle {
fill: none; fill: none;
} }
.hidden { :local(.hidden) {
display: none; display: none;
} }
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
import * as d3 from "d3";
const Lasso = () => {
const dispatch = d3.dispatch("start", "end", "cancel");
const polygonToPath = polygon =>
`M${polygon.map(d => d.join(",")).join("L")}`;
const distance = (pt1, pt2) =>
Math.sqrt((pt2[0] - pt1[0]) ** 2 + (pt2[1] - pt1[1]) ** 2);
// distance last point has to be to first point before it auto closes when mouse is released
const closeDistance = 75;
const lasso = svg => {
let lassoPolygon;
let lassoPath;
let closePath;
const handleDragStart = () => {
lassoPolygon = [d3.mouse(svg.node())]; // current x y of mouse within element
if (lassoPath) {
lassoPath.remove();
}
lassoPath = g
.append("path")
.attr("data-testid", "lasso-element")
.attr("fill", "#0bb")
.attr("fill-opacity", 0.1)
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3");
closePath = g
.append("line")
.attr("x2", lassoPolygon[0][0])
.attr("y2", lassoPolygon[0][1])
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3")
.attr("opacity", 0);
dispatch.call("start", lasso, lassoPolygon);
};
const handleDrag = () => {
const point = d3.mouse(svg.node());
lassoPolygon.push(point);
lassoPath.attr("d", polygonToPath(lassoPolygon));
// indicate if we are within closing distance
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
closePath
.attr("x1", point[0])
.attr("y1", point[1])
.attr("opacity", 1);
} else {
closePath.attr("opacity", 0);
}
};
const handleDragEnd = () => {
// remove the close path
closePath.remove();
closePath = null;
// succesfully closed
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
dispatch.call("end", lasso, lassoPolygon);
// otherwise cancel
} else {
lassoPath.remove();
lassoPath = null;
lassoPolygon = null;
dispatch.call("cancel");
}
};
// append a <g> with a rect
const g = svg.append("g").attr("class", "lasso-group");
const bbox = svg.node().getBoundingClientRect();
const area = g
.append("rect")
.attr("width", bbox.width)
.attr("height", bbox.height)
.attr("fill", "tomato")
.attr("opacity", 0);
const drag = d3
.drag()
.on("start", handleDragStart)
.on("drag", handleDrag)
.on("end", handleDragEnd);
area.call(drag);
lasso.reset = () => {
if (lassoPath) {
lassoPath.remove();
lassoPath = null;
}
lassoPolygon = null;
if (closePath) {
closePath.remove();
closePath = null;
}
};
lasso.move = polygon => {
if (polygon !== lassoPolygon || polygon.length !== lassoPolygon.length) {
lasso.reset();
lassoPolygon = polygon;
lassoPath = g
.append("path")
.attr("data-testid", "lasso-element")
.attr("fill", "#0bb")
.attr("fill-opacity", 0.1)
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3");
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
}
};
};
lasso.on = (type, callback) => {
dispatch.on(type, callback);
return lasso;
};
return lasso;
};
export default Lasso;
@@ -0,0 +1,62 @@
// jshint esversion: 6
import * as d3 from "d3";
import styles from "./graph.css";
import Lasso from "./setupLasso";
/******************************************
*******************************************
put svg & brush in DOM
*******************************************
******************************************/
export default (
selectionToolType,
handleStartAction,
handleDragAction,
handleEndAction,
handleCancelAction,
responsive,
graphPaddingRight
) => {
const svg = d3
.select("#graphAttachPoint")
.append("svg")
.attr("data-testid", "layout-overlay")
.attr("width", responsive.width - graphPaddingRight)
.attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`);
if (selectionToolType === "brush") {
const brush = d3
.brush()
.extent([
[0, 0],
[responsive.width - graphPaddingRight, responsive.height]
])
.on("start", handleStartAction)
.on("brush", handleDragAction)
// FYI, brush doesn't generate cancel
.on("end", handleEndAction);
const brushContainer = svg
.append("g")
.attr("class", "graph_brush")
.call(brush);
return { svg, container: brushContainer, tool: brush };
}
if (selectionToolType === "lasso") {
const lasso = Lasso()
.on("end", handleEndAction)
// FYI, Lasso doesn't generate drag
.on("start", handleStartAction)
.on("cancel", handleCancelAction);
const lassoContainer = svg.call(lasso);
return { svg, container: lassoContainer, tool: lasso };
}
throw new Error("unknown graph selection tool");
};
+77
View File
@@ -0,0 +1,77 @@
// jshint esversion: 6
import _ from "lodash";
import React from "react";
import { connect } from "react-redux";
import Categorical from "./categorical/categorical";
import Continuous from "./continuous/continuous";
import GeneExpression from "./geneExpression";
import * as globals from "../globals";
import DynamicScatterplot from "./scatterplot/scatterplot";
@connect(state => ({
responsive: state.responsive,
datasetTitle: _.get(state.config, "displayNames.dataset"),
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class LeftSideBar extends React.Component {
render() {
const {
responsive,
datasetTitle,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
/*
this magic number should be made less fragile,
if cellxgene logo or tabs change, this must as well
*/
const metadataSectionPadding = 0;
// scatterplotXXaccessor && scatterplotYYaccessor ? 450 : 0;
return (
<div
style={{
position: "fixed",
backgroundColor: "white",
/* x y blur spread color */
boxShadow: "1px 0px 6px 2px rgba(153,153,153,0.4)"
}}
>
<p
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
style={{
height: responsive.height - metadataSectionPadding,
width: globals.leftSidebarWidth,
overflowY: "auto",
overflowX: "hidden"
}}
>
<Categorical />
<GeneExpression />
<Continuous />
</div>
{scatterplotXXaccessor && scatterplotYYaccessor ? (
<DynamicScatterplot />
) : null}
</div>
);
}
}
export default LeftSideBar;
@@ -38,15 +38,7 @@ export default function(regl) {
uniforms: { uniforms: {
distance: regl.prop("distance"), distance: regl.prop("distance"),
view: regl.prop("view"), view: regl.prop("view"),
projection: (context, props) => { projection: () => mat4.perspective([], Math.PI / 2, 1, 0.01, 1000)
return mat4.perspective(
[],
Math.PI / 2,
context.viewportWidth * props.scale / context.viewportHeight,
0.01,
1000
);
}
}, },
count: regl.prop("count"), count: regl.prop("count"),
@@ -1,15 +1,14 @@
:local(.scatterplot) {
.scatterplot {
display: block; display: block;
} }
.scatterplot svg, :local(.scatterplot) svg,
.scatterplot canvas { :local(.scatterplot) canvas {
font: 10px sans-serif; font: 10px sans-serif;
position: absolute; position: absolute;
} }
.scatterplot canvas { :local(.scatterplot) canvas {
opacity: 0.9; opacity: 0.9;
pointer-events: none; pointer-events: none;
} }
@@ -0,0 +1,384 @@
// jshint esversion: 6
// https://bl.ocks.org/Jverma/076377dd0125b1a508621441752735fc
// https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Button, ButtonGroup } from "@blueprintjs/core";
import _regl from "regl";
import * as d3 from "d3";
import * as globals from "../../globals";
import _camera from "../../util/camera";
import setupScatterplot from "./setupScatterplot";
import styles from "./scatterplot.css";
import _drawPoints from "./drawPointsRegl";
import scaleLinear from "../../util/scaleLinear";
import { margin, width, height } from "./util";
import finiteExtent from "../../util/finiteExtent";
@connect(state => {
const { world, crossfilter } = state;
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
const expressionX =
world &&
scatterplotXXaccessor &&
world.varData.hasCol(scatterplotXXaccessor)
? world.varData.col(scatterplotXXaccessor).asArray()
: null;
const expressionY =
world &&
scatterplotYYaccessor &&
world.varData.hasCol(scatterplotYYaccessor)
? world.varData.col(scatterplotYYaccessor).asArray()
: null;
return {
world,
colorRGB: state.colors.rgb,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
// Accessors are var/gene names (strings)
scatterplotXXaccessor,
scatterplotYYaccessor,
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
differential: state.differential,
expressionX,
expressionY,
crossfilter
};
})
class Scatterplot extends React.Component {
constructor(props) {
super(props);
this.count = 0;
this.axes = false;
this.renderCache = {
positions: null,
colors: null,
sizes: null,
xScale: null,
yScale: null
};
this.state = {
svg: null,
minimized: null
};
}
componentDidMount() {
const { svg } = setupScatterplot(width, height, margin);
let scales;
const { expressionX, expressionY } = this.props;
if (svg && expressionX && expressionY) {
scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
}
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
const regl = _regl(this.reglCanvas);
const drawPoints = _drawPoints(regl);
// preallocate buffers
const pointBuffer = regl.buffer();
const colorBuffer = regl.buffer();
const sizeBuffer = regl.buffer();
const reglRender = regl.frame(() => {
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
camera.tick();
});
this.reglRenderState = "rendering";
this.setState({
regl,
sizeBuffer,
pointBuffer,
colorBuffer,
svg,
reglRender,
camera,
drawPoints
});
}
componentDidUpdate(prevProps) {
const {
world,
crossfilter,
scatterplotXXaccessor,
scatterplotYYaccessor,
expressionX,
expressionY,
colorRGB
} = this.props;
const {
reglRender,
regl,
pointBuffer,
colorBuffer,
sizeBuffer,
svg,
drawPoints,
camera
} = this.state;
if (
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
world !== prevProps.world // shape or clip of world changed
) {
const scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
this.renderCache = { ...this.renderCache, ...scales };
}
if (reglRender && this.reglRenderState === "rendering") {
reglRender.cancel();
this.reglRenderState = "paused";
}
if (
world &&
regl &&
pointBuffer &&
colorBuffer &&
sizeBuffer &&
expressionX &&
expressionY &&
scatterplotXXaccessor &&
scatterplotYYaccessor
) {
const { renderCache } = this;
const { xScale, yScale } = this.renderCache;
const cellCount = expressionX.length;
// Points change when expressionX or expressionY change.
if (
!renderCache.positions ||
expressionX !== prevProps.expressionX ||
expressionY !== prevProps.expressionY
) {
if (!renderCache.positions) {
renderCache.positions = new Float32Array(2 * cellCount);
}
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
const glScaleY = scaleLinear([0, height], [-1, 1]);
for (let i = 0, { positions } = renderCache; i < cellCount; i += 1) {
positions[2 * i] = glScaleX(xScale(expressionX[i]));
positions[2 * i + 1] = glScaleY(yScale(expressionY[i]));
}
pointBuffer({ data: renderCache.positions, dimension: 2 });
}
// Colors for each point - change only when props.colorsRGB change.
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) {
if (!renderCache.colors) {
renderCache.colors = new Float32Array(3 * cellCount);
}
for (let i = 0, { colors } = renderCache; i < cellCount; i += 1) {
colors.set(colorRGB[i], 3 * i);
}
colorBuffer({ data: renderCache.colors, dimension: 3 });
}
// Sizes for each point - updates are triggered only when selected
// obs change
if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) {
if (!renderCache.sizes) {
renderCache.sizes = new Float32Array(cellCount);
}
crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2);
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
}
this.count = cellCount;
regl._refresh();
this.reglDraw(
regl,
drawPoints,
sizeBuffer,
colorBuffer,
pointBuffer,
camera
);
}
}
static setupScales(expressionX, expressionY) {
const xScale = d3
.scaleLinear()
.domain(finiteExtent(expressionX))
.range([0, width]);
const yScale = d3
.scaleLinear()
.domain(finiteExtent(expressionY))
.range([height, 0]);
return {
xScale,
yScale
};
}
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
regl.clear({
depth: 1,
color: [1, 1, 1, 1]
});
drawPoints({
size: sizeBuffer,
distance: camera.distance,
color: colorBuffer,
position: pointBuffer,
count: this.count,
view: camera.view()
});
}
drawAxesSVG(xScale, yScale, svg) {
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
svg.selectAll("*").remove();
// the axes are much cleaner and easier now. No need to rotate and orient
// the axis, just call axisBottom, axisLeft etc.
const xAxis = d3
.axisBottom()
.ticks(7)
.scale(xScale);
const yAxis = d3
.axisLeft()
.ticks(7)
.scale(yScale);
// adding axes is also simpler now, just translate x-axis to (0,height)
// and it's alread defined to be a bottom axis.
svg
.append("g")
.attr("transform", `translate(0,${height})`)
.attr("class", "x axis")
.call(xAxis);
// y-axis is translated to (0,0)
svg
.append("g")
.attr("transform", "translate(0,0)")
.attr("class", "y axis")
.call(yAxis);
// adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10).
svg
.append("text")
.attr("x", 10)
.attr("y", 10)
.attr("class", "label")
.style("font-style", "italic")
.text(scatterplotYYaccessor);
svg
.append("text")
.attr("x", width)
.attr("y", height - 10)
.attr("text-anchor", "end")
.attr("class", "label")
.style("font-style", "italic")
.text(scatterplotXXaccessor);
}
render() {
const { dispatch } = this.props;
const { minimized } = this.state;
return (
<div
style={{
position: "fixed",
bottom: minimized ? -height + -margin.top : 0,
borderRadius: "3px 3px 0px 0px",
left: globals.leftSidebarWidth + globals.scatterplotMarginLeft,
padding: "0px 20px 20px 0px",
backgroundColor: "white",
/* x y blur spread color */
boxShadow: "0px 0px 6px 2px rgba(153,153,153,0.4)"
}}
id="scatterplot_wrapper"
>
<ButtonGroup
style={{
position: "absolute",
right: 5,
top: 5
}}
>
<Button
type="button"
minimal
onClick={() => {
this.setState({ minimized: !minimized });
}}
>
{minimized ? "show scatterplot" : "hide"}
</Button>
<Button
type="button"
minimal
data-testid="clear-scatterplot"
onClick={() =>
dispatch({
type: "clear scatterplot"
})
}
>
remove
</Button>
</ButtonGroup>
<div
className={styles.scatterplot}
id="scatterplot"
style={{
width: `${width + margin.left + margin.right}px`,
height: `${height + margin.top + margin.bottom}px`
}}
>
<canvas
width={width}
height={height}
data-testid="scatterplot"
style={{
marginLeft: margin.left - 7,
marginTop: margin.top
}}
ref={canvas => {
this.reglCanvas = canvas;
}}
/>
</div>
</div>
);
}
}
export default Scatterplot;
@@ -5,15 +5,18 @@
****************************************** ******************************************
******************************************/ ******************************************/
const setupScatterplot = (width, height, margin) => { import * as d3 from "d3";
var container = d3.select("#scatterplot");
var svg = container const setupScatterplot = (width, height, margin) => {
const container = d3.select("#scatterplot");
const svg = container
.append("svg") .append("svg")
.attr("width", width + margin.left + margin.right) .attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom) .attr("height", height + margin.top + margin.bottom)
.attr("data-testid", "scatterplot-svg")
.append("g") .append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); .attr("transform", `translate(${margin.left},${margin.top})`);
return { return {
svg svg
@@ -1,10 +1,5 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; export const margin = { top: 40, right: 5, bottom: 20, left: 60 };
const paddingRight = 120;
const continuousChartWidth = 340;
export const margin = { top: 66, right: 110, bottom: 20, left: 60 };
export const width = 340; export const width = 340;
export const height = 340 - margin.top - margin.bottom; export const height = 340 - margin.top - margin.bottom;
export const innerHeight = height - 2; export const innerHeight = height - 2;
+53 -52
View File
@@ -1,36 +1,24 @@
// jshint esversion: 6 import { Colors } from "@blueprintjs/core";
/* these will be either (preferably) specified or inferred */
export const categories = [ /* if a categorical metadata field has more options than this, truncate */
"Sample.type", export const maxCategoricalOptionsToDisplay = 100;
"Selection",
"Location", /*
"Sample.name", these are default values for configuration the CLI may supply.
"Class", See the REST API and CLI specs for more info.
"Neoplastic" */
]; export const configDefaults = {
export const continuous = [ features: {},
"Total_reads", displayNames: {},
"Unique_reads", parameters: {
"Unique_reads_percent", "max-category-items": 1000
"ERCC_reads", }
"Non_ERCC_reads", };
"ERCC_to_non_ERCC",
"Genes_detected",
"Multimapping_reads_percent",
"Splice_sites_AT.AC",
"Splice_sites_Annotated",
"Splice_sites_GC.AG",
"Splice_sites_GT.AG",
"Splice_sites_non_canonical",
"Splice_sites_total",
"Unmapped_mismatch",
"Unmapped_other",
"Unmapped_short"
];
/* colors */ /* colors */
export const blue = "#4a90e2"; export const blue = Colors.BLUE3;
export const hcaBlue = "#1c7cc7"; export const linkBlue = Colors.BLUE5;
export const lightestGrey = "rgb(249,249,249)";
export const lighterGrey = "rgb(245,245,245)"; export const lighterGrey = "rgb(245,245,245)";
export const lightGrey = "rgb(211,211,211)"; export const lightGrey = "rgb(211,211,211)";
export const mediumGrey = "rgb(153,153,153)"; export const mediumGrey = "rgb(153,153,153)";
@@ -41,36 +29,49 @@ export const brightBlue = "#4a90e2";
export const brightGreen = "#A2D729"; export const brightGreen = "#A2D729";
export const darkGreen = "#448C4D"; export const darkGreen = "#448C4D";
export const nonFiniteCellColor = lightGrey;
export const defaultCellColor = "rgb(0,0,0,1)";
/* typography constants */
export const tiniestFontSize = 12; export const tiniestFontSize = 12;
export const largestFontSize = 24;
export const uppercaseLetterSpacing = "0.04em";
export const bolder = 700; export const bolder = 700;
export let API = {
// prefix: "http://api.clustering.czi.technology/api/",
//prefix: "http://tabulamuris.cxg.czi.technology/api/",
prefix: "http://pbmc3k.cxg.czi.technology/api/",
// prefix: "http://pbmc33k.cxg.czi.technology/api/",
// prefix: "http://api-staging.clustering.czi.technology/api/",
version: "v0.1/"
};
if (window.CELLXGENE && window.CELLXGENE.API) API = window.CELLXGENE.API;
export let datasetTitle = "";
if (window.CELLXGENE && window.CELLXGENE.datasetTitle)
datasetTitle = window.CELLXGENE.datasetTitle;
export const accentFont = "Georgia,Times,Times New Roman,serif"; export const accentFont = "Georgia,Times,Times New Roman,serif";
export const maxParagraphWidth = 600; export const maxParagraphWidth = 600;
/* layout styles constants */
export const cellxgeneTitleLeftPadding = 14;
export const cellxgeneTitleTopPadding = 7;
export const maxControlsWidth = 800; export const maxControlsWidth = 800;
export const graphMargin = { top: 20, right: 10, bottom: 30, left: 40 }; export const graphMargin = { top: 20, right: 10, bottom: 30, left: 40 };
// export const graphWidth = 1440 /* window width */ - 410 /* sidebar */ - (15 + 15) /* left right padding */ /* but responsive */;
// export const graphHeight = 500;
export const graphWidth = 700; export const graphWidth = 700;
export const graphHeight = 700; export const graphHeight = 700;
export const scatterplotMarginLeft = 25;
export const leftSidebarWidth = 365;
export const leftSidebarSectionHeading = {
fontSize: 18,
textTransform: "uppercase",
fontWeight: 500,
letterSpacing: ".05em"
};
export const leftSidebarSectionPadding = 10;
let _API = {
// prefix: "http://api.clustering.czi.technology/api/",
// prefix: "http://tabulamuris.cxg.czi.technology/api/",
// prefix: "http://api-staging.clustering.czi.technology/api/",
prefix: "http://localhost:5005/api/",
version: "v0.2/"
};
if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.API;
export const API = _API;
export const ordinalColors = [ export const ordinalColors = [
"#0ac115", "#0ac115",
+9
View File
@@ -0,0 +1,9 @@
/*
>>>GLOBAL<<< CSS imports & definitions
Do NOT put anything in this file that is not global. We do not perform
namespace mangling on these definitions. See webpack config for specifics.
*/
@import "~@blueprintjs/core/lib/css/blueprint.css";
@import "~@blueprintjs/icons/lib/css/blueprint-icons.css";
@import "~@blueprintjs/select/lib/css/blueprint-select.css";
+21
View File
@@ -0,0 +1,21 @@
// jshint esversion: 6
/* eslint-disable no-console */
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { FocusStyleManager } from "@blueprintjs/core";
import "./index.css";
/* our code */
import App from "./components/app";
import store from "./reducers";
FocusStyleManager.onlyShowFocusOnTabs();
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
+44
View File
@@ -0,0 +1,44 @@
export default function cascadeReducers(arg) {
/*
Combined a set of cascading reducers into a single reducer. Cascading
reducers are reducers which may rely on state computed by another reducer.
Therefore, they:
- must be composed in a particular order (currently, this is a simple
linear list of reducers, run in list order)
- must have access to partially updated "next state" so they can further
derive state.
Parameter is one of:
- a Map object
- an array of tuples, [ [key1, reducer1], [key2, reducer2], ... ]
Ie, cascadeReducers([ ["a", reduceA], ["b", reduceB] ])
Each reducer will be called with the sigature:
(prevState, action, sharedNextState, sharedPrevState) => newState
cascadeReducers will build a composite newState object, much
like combinedReducers. Additional semantics:
- reducers guaranteed to be called in order
- each reducer will receive shared objects
*/
const reducers = arg instanceof Map ? arg : new Map(arg);
const reducerKeys = [...reducers.keys()];
return (prevState, action) => {
const nextState = {};
let stateChange = false;
for (let i = 0, l = reducerKeys.length; i < l; i += 1) {
const key = reducerKeys[i];
const reducer = reducers.get(key);
const prevStateForKey = prevState ? prevState[key] : undefined;
const nextStateForKey = reducer(
prevStateForKey,
action,
nextState,
prevState
);
nextState[key] = nextStateForKey;
stateChange = stateChange || nextStateForKey !== prevStateForKey;
}
return stateChange ? nextState : prevState;
};
}
+106
View File
@@ -0,0 +1,106 @@
import _ from "lodash";
import { ControlsHelpers } from "../util/stateManager";
import * as globals from "../globals";
function maxCategoryItems(state) {
return _.get(
state.config,
"parameters.max-category-items",
globals.configDefaults.parameters["max-category-items"]
);
}
const CategoricalSelection = (
state,
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "set World to current selection":
case "reset World to eq Universe":
case "set clip quantiles": {
const { world } = nextSharedState;
return ControlsHelpers.createCategoricalSelection(
maxCategoryItems(prevSharedState),
world
);
}
case "categorical metadata filter select": {
/*
Set the specific category in this field to false
*/
const newCategorySelected = Array.from(
state[action.metadataField].categorySelected
);
newCategorySelected[action.categoryIndex] = true;
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categorySelected: newCategorySelected
}
};
return newCategoricalSelection;
}
case "categorical metadata filter deselect": {
/*
Set the specific category in this field to false
*/
const newCategorySelected = Array.from(
state[action.metadataField].categorySelected
);
newCategorySelected[action.categoryIndex] = false;
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categorySelected: newCategorySelected
}
};
return newCategoricalSelection;
}
case "categorical metadata filter none of these": {
/*
set all categories in this field to false.
*/
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categorySelected: Array.from(
state[action.metadataField].categorySelected
).fill(false)
}
};
return newCategoricalSelection;
}
case "categorical metadata filter all of these": {
/*
set all categories in this field to true.
*/
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categorySelected: Array.from(
state[action.metadataField].categorySelected
).fill(true)
}
};
return newCategoricalSelection;
}
default: {
return state;
}
}
};
export default CategoricalSelection;
+100
View File
@@ -0,0 +1,100 @@
import { createColors } from "../util/stateManager";
const ColorsReducer = (
state = {
colorMode: null,
colorAccessor: null,
rgb: null,
scale: null
},
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "reset World to eq Universe": {
const { world } = nextSharedState;
const colorMode = null;
const colorAccessor = null;
const { rgb, scale } = createColors(world, colorMode);
return {
...state,
colorAccessor,
colorMode,
rgb,
scale
};
}
case "set clip quantiles":
case "set World to current selection": {
const { colorMode, colorAccessor } = state;
const { world } = nextSharedState;
const { rgb, scale } = createColors(world, colorMode, colorAccessor);
return {
...state,
rgb,
scale
};
}
case "reset colorscale": {
const { world } = prevSharedState;
const { rgb, scale } = createColors(world);
return {
...state,
colorMode: null,
colorAccessor: null,
rgb,
scale
};
}
case "color by categorical metadata":
case "color by continuous metadata": {
const { world } = prevSharedState;
/* toggle between this mode and reset */
const resetCurrent =
action.type === state.colorMode &&
action.colorAccessor === state.colorAccessor;
const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.colorAccessor : null;
const { rgb, scale } = createColors(world, colorMode, colorAccessor);
return {
...state,
colorMode,
colorAccessor,
rgb,
scale
};
}
case "color by expression": {
const { world } = prevSharedState;
/* toggle between this mode and reset */
const resetCurrent =
action.type === state.colorMode && action.gene === state.colorAccessor;
const colorMode = !resetCurrent ? action.type : null;
const colorAccessor = !resetCurrent ? action.gene : null;
const { rgb, scale } = createColors(world, colorMode, colorAccessor);
return {
...state,
colorMode,
colorAccessor,
rgb,
scale
};
}
default: {
return state;
}
}
};
export default ColorsReducer;
+34
View File
@@ -0,0 +1,34 @@
// jshint esversion: 6
const Config = (
state = {
displayNames: null,
features: null,
parameters: null
},
action
) => {
switch (action.type) {
case "initial data load start":
return {
...state,
loading: true,
error: null
};
case "configuration load complete":
return {
...state,
loading: false,
error: null,
...action.config
};
case "initial data load error":
return {
...state,
error: action.error
};
default:
return state;
}
};
export default Config;
@@ -0,0 +1,35 @@
import { makeContinuousDimensionName } from "../util/nameCreators";
const ContinuousSelection = (state = {}, action) => {
switch (action.type) {
case "reset World to eq Universe":
case "set clip quantiles": {
return {};
}
case "continuous metadata histogram start":
case "continuous metadata histogram brush":
case "continuous metadata histogram end": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
action.selection
);
return {
...state,
[name]: action.range
};
}
case "continuous metadata histogram cancel": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
action.selection
);
const { [name]: deletedField, ...newState } = state;
return newState;
}
default: {
return state;
}
}
};
export default ContinuousSelection;
+184
View File
@@ -0,0 +1,184 @@
// jshint esversion: 6
import _ from "lodash";
import { WorldUtil } from "../util/stateManager";
const Controls = (
state = {
// data loading flag
loading: false,
error: null,
// all of the data + selection state
userDefinedGenes: [],
userDefinedGenesLoading: false,
diffexpGenes: [],
resettingInterface: false,
opacityForDeselectedCells: 0.2,
scatterplotXXaccessor: null, // just easier to read
scatterplotYYaccessor: null,
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
__storedStateForCelllist1__: null /* will need procedural control of brush ie., brush.extent https://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81 */,
__storedStateForCelllist2__: null
},
action,
nextSharedState,
prevSharedState
) => {
/*
For now, log anything looking like an error to the console.
*/
if (action.error || /error/i.test(action.type)) {
console.error(action.error);
}
switch (action.type) {
/*****************************************************
Initialization, World/Universe management
and data loading.
******************************************************/
case "initial data load start": {
return { ...state, loading: true };
}
case "initial data load complete (universe exists)": {
/* first light - create world & other data-driven defaults */
WorldUtil.clearCaches();
return {
...state,
loading: false,
error: null,
resettingInterface: false
};
}
case "reset World to eq Universe": {
WorldUtil.clearCaches();
return {
...state,
resettingInterface: false
};
}
case "set World to current selection": {
WorldUtil.clearCaches();
return {
...state,
loading: false,
error: null
};
}
case "request user defined gene started": {
return {
...state,
userDefinedGenesLoading: true
};
}
case "request user defined gene error": {
return {
...state,
userDefinedGenesLoading: false
};
}
case "request user defined gene success": {
const { userDefinedGenes } = state;
const _userDefinedGenes = _.uniq(
userDefinedGenes.concat(action.data.genes)
);
return {
...state,
userDefinedGenes: _userDefinedGenes,
userDefinedGenesLoading: false
};
}
case "request differential expression success": {
const { world } = prevSharedState;
const _diffexpGenes = [];
action.data.forEach(d => {
_diffexpGenes.push(world.varAnnotations.at(d[0], "name"));
});
return {
...state,
diffexpGenes: _diffexpGenes
};
}
case "clear differential expression": {
return {
...state,
diffexpGenes: []
};
}
case "clear user defined gene": {
const { userDefinedGenes } = state;
const newUserDefinedGenes = _.filter(
userDefinedGenes,
d => d !== action.data
);
return {
...state,
userDefinedGenes: newUserDefinedGenes
};
}
case "clear all user defined genes": {
return {
...state,
userDefinedGenes: []
};
}
case "expression load error":
case "initial data load error": {
return {
...state,
loading: false,
error: action.error
};
}
/*******************************
User Events
*******************************/
case "change opacity deselected cells in 2d graph background":
return {
...state,
opacityForDeselectedCells: action.data
};
case "increment graph render counter": {
const c = state.graphRenderCounter + 1;
return {
...state,
graphRenderCounter: c
};
}
case "interface reset started": {
return {
...state,
resettingInterface: true
};
}
/*******************************
Scatterplot
*******************************/
case "set scatterplot x":
return {
...state,
scatterplotXXaccessor: action.data
};
case "set scatterplot y":
return {
...state,
scatterplotYYaccessor: action.data
};
case "clear scatterplot":
return {
...state,
scatterplotXXaccessor: null,
scatterplotYYaccessor: null
};
default:
return state;
}
};
export default Controls;
+189
View File
@@ -0,0 +1,189 @@
import _ from "lodash";
import Crossfilter from "../util/typedCrossfilter";
import { World, ControlsHelpers } from "../util/stateManager";
import {
layoutDimensionName,
obsAnnoDimensionName,
userDefinedDimensionName,
diffexpDimensionName,
makeContinuousDimensionName
} from "../util/nameCreators";
const XYDimName = layoutDimensionName("XY");
const CrossfilterReducer = (
state = null,
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { world } = nextSharedState;
const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations),
world
);
return crossfilter;
}
case "reset World to eq Universe": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world } = nextSharedState;
const crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
prevSharedState.resetCache.crossfilter
);
return crossfilter;
}
case "set clip quantiles":
case "set World to current selection": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world } = nextSharedState;
let crossfilter = new Crossfilter(world.obsAnnotations);
crossfilter = World.createObsDimensions(crossfilter, world);
crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
);
return crossfilter;
}
case "request user defined gene success": {
const { world } = prevSharedState;
const gene = action.data.genes[0];
return state.addDimension(
userDefinedDimensionName(gene),
"scalar",
world.varData.col(gene).asArray(),
Float32Array
);
}
case "request differential expression success": {
const { world } = prevSharedState;
const genes = _.map(action.data, d =>
world.varAnnotations.at(d[0], "name")
);
const crossfilter = _.reduce(
genes,
(xfltr, gene) =>
xfltr.addDimension(
diffexpDimensionName(gene),
"scalar",
world.varData.col(gene).asArray(),
Float32Array
),
state
);
return crossfilter;
}
case "clear differential expression": {
const { world } = prevSharedState;
const crossfilter = _.reduce(
action.diffExp,
(xfltr, values) => {
const name = world.varAnnotations.at(values[0], "name");
return xfltr.delDimension(diffexpDimensionName(name));
},
state
);
return crossfilter;
}
case "clear user defined gene": {
return state.delDimension(userDefinedDimensionName(action.data));
}
case "clear all user defined genes": {
const { userDefinedGenes } = prevSharedState.controls;
const crossfilter = _.reduce(
userDefinedGenes,
(xfltr, gene) => xfltr.delDimension(userDefinedDimensionName(gene)),
state
);
return crossfilter;
}
case "graph brush end":
case "graph brush change": {
const [minX, maxY] = action.brushCoords.northwest;
const [maxX, minY] = action.brushCoords.southeast;
return state.select(XYDimName, {
mode: "within-rect",
minX,
minY,
maxX,
maxY
});
}
case "graph lasso end": {
const { polygon } = action;
return state.select(XYDimName, {
mode: "within-polygon",
polygon
});
}
case "graph lasso cancel":
case "graph brush cancel":
case "graph lasso deselect":
case "graph brush deselect": {
return state.select(XYDimName, { mode: "all" });
}
case "continuous metadata histogram start":
case "continuous metadata histogram brush":
case "continuous metadata histogram cancel":
case "continuous metadata histogram end": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
action.selection
);
// action.selection: metadata name being selected
// action.range: filter range, or null if deselected
if (!action.range) {
return state.select(name, { mode: "all" });
}
const [lo, hi] = action.range;
const newState = state.select(name, { mode: "range", lo, hi });
return newState;
}
case "categorical metadata filter select":
case "categorical metadata filter deselect": {
const { categoricalSelection } = nextSharedState;
const cat = categoricalSelection[action.metadataField];
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "exact",
values: ControlsHelpers.selectedValuesForCategory(cat)
});
}
case "categorical metadata filter none of these": {
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "none"
});
}
case "categorical metadata filter all of these": {
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "all"
});
}
default: {
return state;
}
}
};
export default CrossfilterReducer;
@@ -1,4 +1,5 @@
// jshint esversion: 6 // jshint esversion: 6
const Differential = ( const Differential = (
state = { state = {
diffExp: null, diffExp: null,
@@ -11,29 +12,49 @@ const Differential = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "request differential expression started": case "request differential expression started":
return Object.assign({}, state, { return {
...state,
loading: true, loading: true,
error: null error: null
}); };
case "request differential expression success": case "request differential expression success":
return Object.assign({}, state, { return {
...state,
error: null, error: null,
loading: false, loading: false,
diffExp: action.data diffExp: action.data
}); };
case "request differential expression error": case "request differential expression error":
return Object.assign({}, state, { return {
...state,
loading: false, loading: false,
error: action.data error: action.data
}); };
case "store current cell selection as differential set 1": case "store current cell selection as differential set 1":
return Object.assign({}, state, { return {
...state,
celllist1: action.data celllist1: action.data
}); };
case "store current cell selection as differential set 2": case "store current cell selection as differential set 2":
return Object.assign({}, state, { return {
...state,
celllist2: action.data celllist2: action.data
}); };
case "clear differential expression":
return {
...state,
diffExp: null,
celllist1: null,
celllist2: null
};
case "reset World to eq Universe":
case "set World to current selection":
return {
...state,
diffExp: null,
celllist1: null,
celllist2: null
};
default: default:
return state; return state;
} }
+60
View File
@@ -0,0 +1,60 @@
const GraphSelection = (
state = {
tool: "lasso", // what selection tool mode (lasso, brush, ...)
selection: { mode: "all" } // current selection, which is tool specific
},
action
) => {
switch (action.type) {
case "set clip quantiles":
case "reset World to eq Universe": {
return {
...state,
selection: {
mode: "all"
}
};
}
case "graph brush end":
case "graph brush change": {
const { brushCoords } = action;
return {
...state,
selection: {
mode: "within-rect",
brushCoords
}
};
}
case "graph lasso end": {
const { polygon } = action;
return {
...state,
selection: {
mode: "within-polygon",
polygon
}
};
}
case "graph lasso cancel":
case "graph brush cancel":
case "graph lasso deselect":
case "graph brush deselect": {
return {
...state,
selection: {
mode: "all"
}
};
}
default: {
return state;
}
}
};
export default GraphSelection;
+51
View File
@@ -0,0 +1,51 @@
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import cascadeReducers from "./cascade";
import undoable from "./undoable";
import config from "./config";
import universe from "./universe";
import world from "./world";
import categoricalSelection from "./categoricalSelection";
import continuousSelection from "./continuousSelection";
import graphSelection from "./graphSelection";
import crossfilter from "./crossfilter";
import colors from "./colors";
import differential from "./differential";
import responsive from "./responsive";
import controls from "./controls";
import resetCache from "./resetCache";
import undoableConfig from "./undoableConfig";
const Reducer = undoable(
cascadeReducers([
["config", config],
["universe", universe],
["world", world],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
["graphSelection", graphSelection],
["crossfilter", crossfilter],
["colors", colors],
["controls", controls],
["differential", differential],
["responsive", responsive],
["resetCache", resetCache]
]),
[
"world",
"categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
"colors",
"controls",
"differential"
],
undoableConfig
);
const store = createStore(Reducer, applyMiddleware(thunk));
export default store;
+31
View File
@@ -0,0 +1,31 @@
/*
Reducer which caches derived state to be used in a reset or other
recomputation.
Currently this only caches the baseline (full universe) world & crossfilter,
for use in a Reset.
*/
const ResetCacheReducer = (
state = {
world: null,
crossfilter: null
},
action,
nextSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { world, crossfilter } = nextSharedState;
return {
...state,
world,
crossfilter
};
}
default: {
return state;
}
}
};
export default ResetCacheReducer;
@@ -8,10 +8,11 @@ const Responsive = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "window resize": case "window resize":
return Object.assign({}, state, { return {
...state,
width: action.data.width, width: action.data.width,
height: action.data.height height: action.data.height
}); };
default: default:
return state; return state;
} }
+287
View File
@@ -0,0 +1,287 @@
/*
A redo/undo meta reducer for Redux. Designed to work well with the cascadeReducer().
Requires three parameters:
* reducer - a reducer, which MUST return an object as state.
* undoableKeys - an array of object keys (strings). If any of these keys
are in the object/state returned by the reducer, they will be treated as
state to be made "undoable".
* options - an optional object, which may contain the following parameters:
* historyLimit: max number of historical states to remember (aka max undo depth)
* actionFilter: filter function, (state, action, filterState) => value.
See below for details.
* debug: if truish, will print helpful log messages about history manipulation
This meta reducer accepts three actions types:
* @@undoable/undo - move back in history
* @@undoable/redo - move forward in history
* @@undoable/clear - clear history
---
Action filter - controls the undoable reducer side-effects. If not
specified, the action filter defaults to "save", ie, pushes a redo
point upon each action.
The action filter callback has access to the current action, the entire
undoable reducer state, and any state it wants to manage ("filterState").
This filter state will be passed to each action filter call, and any
value returned (via @@undoable/filterState field described below) will be
MERGED into the current filter state.
An object must be returned (the "undoable action"), indicating desired
history state processing. The undoable action object contents, by key:
@@undoable/filterAction: required. Can be one of:
"skip" - reduce the current action, but no other side effects.
Same as returning false.
"clear" - reduce the current action, and clear history state.
"save" - push the previous state onto the history stack (ie,
before reducing the action)
"stashPending" - reduce action, save state as pending. Does not
not commit it to history. Along with cancelPending and applyPending,
can be used to delay commit of history (eg, for multi-action
groupings, asynch operations, etc).
"cancelPending" - reduce action, cancel any pending state save.
"applyPending" - commit any pending state to the history stack,
then reduce action.
@@undoable/filterState: optional. If this value is set, it will be
MERGED into the current filter state. The value and semantics of any
filter state are entirely at the discretion of the action filter.
*/
import fromEntries from "../util/fromEntries";
const historyKeyPrefix = "@@undoable/";
const pastKey = `${historyKeyPrefix}past`;
const futureKey = `${historyKeyPrefix}future`;
const filterStateKey = `${historyKeyPrefix}filterState`;
const filterActionKey = `${historyKeyPrefix}filterAction`;
const pendingKey = `${historyKeyPrefix}pending`;
const defaultHistoryLimit = -100;
const Undoable = (reducer, undoableKeys, options = {}) => {
const { debug } = options;
let { historyLimit } = options;
if (!historyLimit) historyLimit = defaultHistoryLimit;
if (historyLimit > 0) historyLimit = -historyLimit;
const actionFilter =
options.actionFilter || (() => ({ [filterActionKey]: "save" }));
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
throw new Error("undoable keys array must be specified");
const undoableKeysSet = new Set(undoableKeys);
/*
Undo the current to previous history
*/
function undo(currentState) {
const past = currentState[pastKey];
const future = currentState[futureKey];
if (past.length === 0) return currentState;
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
const newPast = [...past];
const newState = newPast.pop();
const newFuture = push(future, currentUndoableState);
const nextState = {
...currentState,
...fromEntries(newState),
[pastKey]: newPast,
[futureKey]: newFuture,
[pendingKey]: null
};
return nextState;
}
/*
Replay future, previously undone.
*/
function redo(currentState) {
const past = currentState[pastKey] || [];
const future = currentState[futureKey] || [];
if (future.length === 0) return currentState;
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
const newFuture = [...future];
const newState = newFuture.pop();
const newPast = push(past, currentUndoableState);
const nextState = {
...currentState,
...fromEntries(newState),
[pastKey]: newPast,
[futureKey]: newFuture,
[pendingKey]: null
};
return nextState;
}
/*
Clear the history state. No side-effects on current state.
*/
function clear(currentState) {
return {
...currentState,
[pastKey]: [],
[futureKey]: [],
[filterStateKey]: {},
[pendingKey]: null
};
}
/*
Reduce current action, with no history side-effects
*/
function skip(currentState, action, filterState) {
const past = currentState[pastKey] || [];
const pending = currentState[pendingKey];
const res = reducer(currentState, action);
return {
...res,
[pastKey]: past,
[futureKey]: [],
[filterStateKey]: filterState,
[pendingKey]: pending
};
}
/*
Save current state in the history, then reduce action.
*/
function save(currentState, action, filterState) {
const past = currentState[pastKey] || [];
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
const res = reducer(currentState, action);
const newPast = push(past, currentUndoableState, historyLimit);
const nextState = {
...res,
[pastKey]: newPast,
[futureKey]: [],
[filterStateKey]: filterState,
[pendingKey]: null
};
return nextState;
}
/*
Save current state as pending history change. No other side effects.
*/
function stashPending(currentState) {
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
return {
...currentState,
[pendingKey]: currentUndoableState
};
}
/*
Cancel pending history state change. No other side effects.
*/
function cancelPending(currentState) {
return {
...currentState,
[pendingKey]: null
};
}
/*
Push pending state onto the history stack
*/
function applyPending(currentState) {
const past = currentState[pastKey] || [];
const pendingState = currentState[pendingKey];
const newPast = push(past, pendingState, historyLimit);
const nextState = {
...currentState,
[pastKey]: newPast,
[futureKey]: [],
[pendingKey]: null
};
return nextState;
}
return (
currentState = {
[pastKey]: [],
[futureKey]: [],
[filterStateKey]: {},
[pendingKey]: null
},
action
) => {
const aType = action.type;
switch (aType) {
case "@@undoable/undo": {
return undo(currentState, action);
}
case "@@undoable/redo": {
return redo(currentState, action);
}
case "@@undoable/clear": {
return clear(currentState, action);
}
default: {
const currentFilterState = currentState[filterStateKey];
const actionFilterResp = actionFilter(
currentState,
action,
currentFilterState
);
const {
[filterActionKey]: filterAction,
[filterStateKey]: filterStateUpdate
} = actionFilterResp;
const nextFilterState = { ...currentFilterState, ...filterStateUpdate };
switch (filterAction) {
case "clear":
if (debug) console.log("---- CLEAR HISTO", action.type);
return clear(skip(currentState, action, nextFilterState));
case "save":
if (debug) console.log("---- SAVE HISTO", action.type);
return save(currentState, action, nextFilterState);
case "stashPending":
if (debug) console.log("---- STASH PENDING", action.type);
return skip(stashPending(currentState), action, nextFilterState);
case "cancelPending":
if (debug) console.log("---- CANCEL PENDING", action.type);
return skip(cancelPending(currentState), action, nextFilterState);
case "applyPending":
if (debug) console.log("---- APPLY PENDING", action.type);
return skip(applyPending(currentState), action, nextFilterState);
case "skip":
default:
return skip(currentState, action, nextFilterState);
}
}
}
};
};
function push(arr, val, limit = undefined) {
/*
functional array push, with a max length limit to the new array.
Like Array.push, except it returns new array and discards as needed
to enforce the length limit.
*/
const narr = arr.slice(limit);
narr.push(val);
return narr;
}
export default Undoable;
+251
View File
@@ -0,0 +1,251 @@
import StateMachine from "../util/statemachine";
import createFsmTransitions from "./undoableFsm";
const actionKey = "@@undoable/filterAction";
const stateKey = "@@undoable/filterState";
/*
these actions will not affect history
*/
const skipOnActions = new Set([
"url changed",
"interface reset started",
"initial data load start",
"configuration load complete",
"increment graph render counter",
"window resize",
"user reset start",
"reset colorscale",
"graph brush change",
"continuous metadata histogram brush",
"expression load start",
"expression load success",
"expression load error",
"request user defined gene started",
"request user defined gene success",
"clear all user defined genes",
"get single gene expression for coloring started",
"get single gene expression for coloring error"
]);
/*
identical, repeated occurances of these action types will be debounced.
Entire action must be identical (all keys).
*/
const debounceOnActions = new Set([
"color by categorical metadata",
"color by continuous metadata",
"color by expression"
]);
/*
history will be cleared when these actions occur
*/
const clearOnActions = new Set([
"initial data load complete (universe exists)",
"reset World to eq Universe",
"initial data load error",
"user reset end"
]);
/*
An immediate history save will be done for these
*/
const saveOnActions = new Set([
"categorical metadata filter select",
"categorical metadata filter deselect",
"categorical metadata filter all of these",
"categorical metadata none of these",
"color by categorical metadata",
"color by continuous metadata",
"color by expression",
"set scatterplot x",
"set scatterplot y",
"store current cell selection as differential set 1",
"store current cell selection as differential set 2",
"set World to current selection",
"set clip quantiles"
]);
/**
StateMachine - processing complex action handling - see FSM graph for
actual structure, in undoableFsm.js
**/
/*
Default FSM actions. Used to side-effect transitions in the graph.
See graph definition for the transitions that use each.
Signature: (fsm, transition, reducerState, reducerAction) => undoableAction
*/
const stashPending = fsm => ({
[actionKey]: "stashPending",
[stateKey]: { fsm }
});
const cancelPending = () => ({
[actionKey]: "cancelPending",
[stateKey]: { fsm: null }
});
const applyPending = () => ({
[actionKey]: "applyPending",
[stateKey]: { fsm: null }
});
const skip = fsm => ({ [actionKey]: "skip", [stateKey]: { fsm } });
const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } });
const save = fsm => ({ [actionKey]: "save", [stateKey]: { fsm } });
/*
Error handler for state transitions that are unexpected. Called by
StateMachine when it doesn't know what to do.
Signature: (fsm, event, from) => undoableAction
*/
const onFsmError = (fsm, event, from) => {
console.error(`FSM error [event: "${event}", state: "${from}"]`, fsm);
// In production, try to recover gracefully if we have unexpected state
return clear(fsm);
};
/*
Definition of the transition graph mapping action types to history side effects.
*/
const fsmTransitions = createFsmTransitions(
stashPending,
cancelPending,
applyPending,
skip,
clear,
save
);
/* State machine we clone whenever we need to run it */
const seedFsm = new StateMachine("init", fsmTransitions, onFsmError);
/*
See undoable.js for description action filter interface description.
Basic approach:
* trivial handlers for skip, clear & save cases to keep config simple.
* only implement complex state machines where absolutely required (eg,
multi-event seleciton and the like)
*/
const actionFilter = debug => (state, action, prevFilterState) => {
const actionType = action.type;
const filterState = {
...prevFilterState,
prevAction: action
};
if (skipOnActions.has(actionType)) {
return { [actionKey]: "skip", [stateKey]: filterState };
}
if (
debounceOnActions.has(actionType) &&
shallowObjectEq(action, prevFilterState.prevAction)
) {
return { [actionKey]: "skip", [stateKey]: filterState };
}
if (clearOnActions.has(actionType)) {
return { [actionKey]: "clear", [stateKey]: filterState };
}
if (saveOnActions.has(actionType)) {
return { [actionKey]: "save", [stateKey]: filterState };
}
/*
Else, something more complex OR unknown to us....
*/
if (seedFsm.events.has(actionType)) {
let { fsm } = filterState;
if (!fsm) {
/* no active FSM, so create one in init state */
fsm = seedFsm.clone("init");
}
return fsm.next(action.type, { state, action });
}
/* else, we have no idea what this is - skip it */
if (debug) console.log("**** ACTION FILTER EVENT HANDLER MISS", actionType);
return { [actionKey]: "skip", [stateKey]: filterState };
};
/*
return true if objA and objB are ===, OR if:
- are both objects and not null
- have same own properties
- all values are strict equal (===)
*/
function shallowObjectEq(objA, objB) {
if (objA === objB) return true;
if (!objA || !objB) return false;
if (!shallowArrayEq(Object.keys(objA), Object.keys(objB))) return false;
if (!shallowArrayEq(Object.values(objA), Object.values(objB))) return false;
return true;
}
/*
return true if arrA and arrB contain the same strict-equal values,
in the same order.
*/
function shallowArrayEq(arrA, arrB) {
if (arrA.length !== arrB.length) return false;
for (let i = 0, l = arrA.length; i < l; i += 1) {
if (arrA[i] !== arrB[i]) return false;
}
return true;
}
/* configuration for the undoable meta reducer */
const debug = false; // set truish for undoble debugging
const undoableConfig = {
debug,
historyLimit: 50, // maximum history size
actionFilter: actionFilter(debug)
};
/*
this code is strictly for sanity checking configuration, and is only
enabled when we are debugging the undoable configuration (ie, debug === true).
*/
if (debug) {
/*
Confirm no intersection between the various trivial rejection action filters
*/
if (
new Set([...skipOnActions].filter(x => clearOnActions.has(x))).size > 0 ||
new Set([...skipOnActions].filter(x => saveOnActions.has(x))).size > 0 ||
new Set([...clearOnActions].filter(x => saveOnActions.has(x))).size > 0
) {
console.error(
"Undoable misconfiguration - action filters have redundant events"
);
}
/*
Confirm that no FSM events are blocked by a trivial rejection filter.
If this occurs, the FSM can't ever see the events needed to process
state transitions.
*/
const trivialFilters = new Set([
...skipOnActions,
...clearOnActions,
...saveOnActions
]);
const trivialOverlapWithFsm = new Set(
[...trivialFilters].filter(x => seedFsm.events.has(x))
);
if (trivialOverlapWithFsm.size > 0) {
console.error(
"Undoable misconfiguration - trivival action filter blocking FSM filter",
[...trivialOverlapWithFsm]
);
}
}
export default undoableConfig;
+206
View File
@@ -0,0 +1,206 @@
/*
State transition graph for complex action/history interactions.
Assumed configuration from undoableConfig:
* By convention, "init" is used as the start state for all, and "done"
as the final state.
* Unexpected states will result in an error, plus a clear and cancelPending
side-effect.
TODO: is is possible there is a more concise format for this, as it is
a fairly repetitive pattern.
These events are largely one of two types:
a) async operations or multi-event options that should only be committed
upon some success criteria, otherwise cancelled.
b) compound actions that should be collapsed into a single history change.
*/
const createFsmTransitions = (
stashPending,
cancelPending,
applyPending,
skip,
clear,
save
) => {
return [
/* graph selection brushing */
{
event: "graph brush start",
from: "init",
to: "graph brush in progress",
action: stashPending
},
{
event: "graph brush cancel",
from: "graph brush in progress",
to: "done",
action: applyPending
},
{
event: "graph brush deselect",
from: "graph brush in progress",
to: "done",
/* if current selection is all, cancelPending. Else, applyPending */
action: (fsm, transition, data) =>
data.state.graphSelection.selection.mode === "all"
? cancelPending()
: applyPending()
},
{
event: "graph brush end",
from: "graph brush in progress",
to: "done",
action: applyPending
},
/* graph selection lasso */
{
event: "graph lasso start",
from: "init",
to: "graph lasso in progress",
action: stashPending
},
{
event: "graph lasso cancel",
from: "graph lasso in progress",
to: "done",
action: applyPending
},
{
event: "graph lasso deselect",
from: "graph lasso in progress",
to: "done",
/* if current selection is all, cancelPending. Else, applyPending */
action: (fsm, transition, data) =>
data.state.graphSelection.selection.mode === "all"
? cancelPending()
: applyPending()
},
{
event: "graph lasso end",
from: "graph lasso in progress",
to: "done",
action: applyPending
},
/* Continuous metadata histogram brush selection */
{
event: "continuous metadata histogram start",
from: "init",
to: "continuous histo select in progress",
action: stashPending
},
{
event: "continuous metadata histogram cancel",
from: "continuous histo select in progress",
to: "done",
action: cancelPending
},
{
event: "continuous metadata histogram end",
from: "continuous histo select in progress",
to: "done",
action: applyPending
},
/* Single gene request by user */
{
event: "single user defined gene start",
from: "init",
to: "single user gene request in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "single user gene request in progress",
to: "single user gene error in progress",
action: skip
},
{
event: "single user defined gene error",
from: "single user gene error in progress",
to: "done",
action: cancelPending
},
{
event: "single user defined gene complete",
from: "single user gene request in progress",
to: "done",
action: applyPending
},
/* Bulk gene request by user */
{
event: "bulk user defined gene start",
from: "init",
to: "bulk user gene request in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "bulk user gene request in progress",
to: "bulk user gene request error in progress",
action: skip
},
{
event: "bulk user defined gene error",
from: "bulk user gene request error in progress",
to: "done",
action: cancelPending
},
{
event: "bulk user defined gene complete",
from: "bulk user gene request in progress",
to: "done",
action: applyPending
},
/* Compute Differential Expression button user action */
{
event: "request differential expression started",
from: "init",
to: "diffexp in progress",
action: stashPending
},
{
event: "request user defined gene error",
from: "diffexp in progress",
to: "done",
action: cancelPending
},
{
event: "request differential expression success",
from: "diffexp in progress",
to: "done",
action: applyPending
},
/* Clear Differential Expression button user action */
{
event: "clear differential expression",
from: "init",
to: "CDE Button in progress",
action: stashPending
},
{
event: "clear scatterplot",
from: "CDE Button in progress",
to: "done",
action: applyPending
},
/* clear scatter plot button (eg, on scatterplot view) */
{
event: "clear scatterplot",
from: "init",
to: "done",
action: save
}
];
};
export default createFsmTransitions;
+47
View File
@@ -0,0 +1,47 @@
import { ControlsHelpers } from "../util/stateManager";
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { universe } = action;
return universe;
}
case "expression load success": {
let { varData } = state;
// Lazy load new expression data into the varData dataframe, if
// not already present.
//
Object.entries(action.expressionData).forEach(([key, val]) => {
// If not already in universe.varData, save entire expression column
if (!varData.hasCol(key)) {
varData = varData.withCol(key, val);
}
});
// Prune size of varData "cache" if getting out of hand....
//
const { userDefinedGenes, diffexpGenes } = prevSharedState;
const allTheGenesWeNeed = [
...new Set(
userDefinedGenes,
diffexpGenes,
Object.keys(action.expressionData)
)
];
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
return {
...state,
varData
};
}
default: {
return state;
}
}
};
export default Universe;
+131
View File
@@ -0,0 +1,131 @@
import { World, ControlsHelpers } from "../util/stateManager";
import clip from "../util/clip";
import quantile from "../util/quantile";
const WorldReducer = (
state = null,
action,
nextSharedState,
prevSharedState
) => {
switch (action.type) {
case "initial data load complete (universe exists)": {
const { universe } = nextSharedState;
const world = World.createWorldFromEntireUniverse(universe);
return world;
}
case "reset World to eq Universe": {
return prevSharedState.resetCache.world;
}
case "set World to current selection": {
/* Set viewable world to be the currently selected data */
const world = World.createWorldBySelection(
action.universe,
action.world,
action.crossfilter
);
return world;
}
case "set clip quantiles": {
const world = World.createWorldWithNewClip(
prevSharedState.universe,
state,
prevSharedState.crossfilter,
action.clipQuantiles
);
return world;
}
case "expression load success": {
const { universe } = nextSharedState;
const universeVarData = universe.varData;
let unclippedVarData = state.unclipped.varData;
// Lazy load new expression data into the unclipped varData dataframe, if
// not already present.
//
Object.entries(action.expressionData).forEach(([key, val]) => {
// If not already in world.varData, save sliced expression column
if (!unclippedVarData.hasCol(key)) {
// Slice if world !== universe, else just use whole column.
// Use the obsAnnotation index as the cut key, as we keep
// all world dataframes in sync.
let worldValSlice = val;
if (!World.worldEqUniverse(state, universe)) {
worldValSlice = universeVarData
.subset(state.obsAnnotations.rowIndex.keys(), [key], null)
.icol(0)
.asArray();
}
// Now build world's varData dataframe
unclippedVarData = unclippedVarData.withCol(
key,
worldValSlice,
state.obsAnnotations.rowIndex
);
}
});
// Prune size of varData unclipped dataframe if getting out of hand....
//
const { userDefinedGenes, diffexpGenes } = prevSharedState;
const allTheGenesWeNeed = [
...new Set(
userDefinedGenes,
diffexpGenes,
Object.keys(action.expressionData)
)
];
unclippedVarData = ControlsHelpers.pruneVarDataCache(
unclippedVarData,
allTheGenesWeNeed
);
// at this point, we have the unclipped data in unclippedVarData.
// Now create clipped.
// - Drop columns no longer needed
// - Add new columns
//
let clippedVarData = state.varData;
const keysToDrop = clippedVarData.colIndex
.keys()
.filter(k => !unclippedVarData.hasCol(k));
const keysToAdd = unclippedVarData.colIndex
.keys()
.filter(k => !clippedVarData.hasCol(k));
keysToDrop.forEach(k => {
clippedVarData = clippedVarData.dropCol(k);
});
keysToAdd.forEach(k => {
const data = unclippedVarData.col(k).asArray();
const q = [state.clipQuantiles.min, state.clipQuantiles.max];
const [qMinVal, qMaxVal] = quantile(q, data);
const clippedData = clip(data, qMinVal, qMaxVal, Number.NaN);
clippedVarData = clippedVarData.withCol(
k,
clippedData,
state.obsAnnotations.rowIndex
);
});
return {
...state,
varData: clippedVarData,
unclipped: {
...state.unclipped,
varData: unclippedVarData
}
};
}
default: {
return state;
}
}
};
export default WorldReducer;
+119
View File
@@ -0,0 +1,119 @@
import _ from "lodash";
/* XXX: cough, cough, ... */
import { postNetworkErrorToast } from "../components/framework/toasters";
/*
dispatch an action error to the user. Currently we use
async toasts.
*/
export const dispatchNetworkErrorMessageToUser = message =>
postNetworkErrorToast(message);
/*
Catch unexpected errors and make sure we don't lose them!
*/
export function catchErrorsWrap(fn, dispatchToUser = false) {
return (dispatch, getState) => {
fn(dispatch, getState).catch(error => {
console.error(error);
if (dispatchToUser) {
dispatchNetworkErrorMessageToUser(error.message);
}
dispatch({ type: "UNEXPECTED ERROR", error });
});
};
}
/*
Wrapper to perform async fetch with some modest error handling
and decoding.
*/
const doFetch = async (url, acceptType) => {
const res = await fetch(url, {
method: "get",
headers: new Headers({
Accept: acceptType
})
});
if (res.ok && res.headers.get("Content-Type").includes(acceptType)) {
return res;
}
// else an error
let msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
const body = await res.text();
if (body && body.length > 0) {
msg = `${msg} -- ${body}`;
}
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
};
/*
Wrapper to perform an async fetch and JSON decode response.
*/
export const doJsonRequest = async url => {
const res = await doFetch(url, "application/json");
return res.json();
};
/*
Wrapper to perform an async fetch for binary data.
*/
export const doBinaryRequest = async url => {
const res = await doFetch(url, "application/octet-stream");
return res.arrayBuffer();
};
/*
This function "packs" filter index lists into the more efficient
"range" form specified in the REST 0.2 spec.
Specifically, it turns an array of indices [0, 1, 2, 10, 11, 14, ...]
into a form that encodes runs of consecutive numbers as [min, max].
Array may not be sorted, but will only contain uniq values.
Parameters:
indices - input array of numbers (index)
minRangeLength - hint, min range length before it is encoded into range format.
sorted - boolean hint indicating array is presorted, ascending order
So [1, 2, 3, 4, 10, 11, 14] -> [ [1, 4], [10, 11], 14]
*/
export const rangeEncodeIndices = (
indices,
minRangeLength = 3,
sorted = false
) => {
if (indices.length === 0) {
return indices;
}
if (!sorted) {
indices = _.sortBy(indices);
}
const result = new Array(indices.length);
let resultTail = 0;
let i = 0;
while (i < indices.length) {
const begin = indices[i];
let current;
do {
current = indices[i];
i += 1;
} while (i < indices.length && indices[i] === current + 1);
if (current - begin + 1 >= minRangeLength) {
result[resultTail] = [begin, current];
resultTail += 1;
} else {
for (let j = begin; j <= current; j += 1, resultTail += 1) {
result[resultTail] = j;
}
}
}
result.length = resultTail;
return result;
};
@@ -1,9 +1,9 @@
// jshint esversion: 6 // jshint esversion: 6
var createCamera = require("orbit-camera"); const createCamera = require("orbit-camera");
var createScroll = require("scroll-speed"); const createScroll = require("scroll-speed");
var mp = require("mouse-position"); const mp = require("mouse-position");
var mb = require("mouse-pressed"); const mb = require("mouse-pressed");
var key = require("key-pressed"); const key = require("key-pressed");
const panSpeed = 0.4; const panSpeed = 0.4;
const scaleSpeed = 0.5; const scaleSpeed = 0.5;
@@ -17,20 +17,19 @@ function attachCamera(canvas, opts) {
opts.scale = opts.scale !== false; opts.scale = opts.scale !== false;
opts.rotate = opts.rotate !== false; opts.rotate = opts.rotate !== false;
var scroll = createScroll(canvas, opts.scale); const scroll = createScroll(canvas, opts.scale);
var mbut = mb(canvas, opts.rotate); const mbut = mb(canvas, opts.rotate);
var mpos = mp(canvas); const mpos = mp(canvas);
var camera = createCamera([0, 0, 1], [0, 0, -1], [0, 1, 0]); const camera = createCamera([0, 0, 1], [0, 0, -1], [0, 1, 0]);
camera.tick = tick; camera.tick = tick;
return camera; return camera;
function tick() { function tick() {
var ctrl = key("<control>") || key("<alt>"); const ctrl = key("<control>") || key("<alt>");
var alt = key("<shift>"); const alt = key("<shift>");
var height = canvas.height; const { height, width } = canvas;
var width = canvas.width;
if (opts.rotate && mbut.left && ctrl && !alt) { if (opts.rotate && mbut.left && ctrl && !alt) {
camera.rotate( camera.rotate(
@@ -41,23 +40,17 @@ function attachCamera(canvas, opts) {
if ((opts.pan && mbut.right) || (mbut.left && !ctrl && !alt)) { if ((opts.pan && mbut.right) || (mbut.left && !ctrl && !alt)) {
camera.pan([ camera.pan([
panSpeed * ((panSpeed * (mpos[0] - mpos.prev[0])) / width) * camera.distance,
(mpos[0] - mpos.prev[0]) / ((panSpeed * (mpos[1] - mpos.prev[1])) / height) * camera.distance
width *
Math.pow(camera.distance, 1),
panSpeed *
(mpos[1] - mpos.prev[1]) /
height *
Math.pow(camera.distance, 1)
]); ]);
} }
if (opts.scale && scroll[1]) { if (opts.scale && scroll[1]) {
camera.distance *= Math.exp(scroll[1] * scaleSpeed / height); camera.distance *= Math.exp((scroll[1] * scaleSpeed) / height);
} }
if (opts.scale && (mbut.middle || (mbut.left && !ctrl && alt))) { if (opts.scale && (mbut.middle || (mbut.left && !ctrl && alt))) {
var d = mpos.y - mpos.prevY; const d = mpos.y - mpos.prevY;
if (!d) return; if (!d) return;
camera.distance *= Math.exp(d / height); camera.distance *= Math.exp(d / height);
+25
View File
@@ -0,0 +1,25 @@
/*
clip - clip all values in a Array or TypedArray, IN PLACE.
Values in array are clipped if less than `lower` or greater than `upper`.
If `setTo` is undefined, values less than `lower` will be set to `lower`,
and values greater than `upper` will be set to `upper`.
If `setTo` is not undefined, values outside the [lower, upper] range will be set to
`setTo`.
*/
export default function clip(arr, lower, upper, setTo) {
const lowerSet = setTo === undefined ? lower : setTo;
const upperSet = setTo === undefined ? upper : setTo;
for (let i = 0, l = arr.length; i < l; i += 1) {
const v = arr[i];
if (v < lower) {
arr[i] = lowerSet;
} else if (v > upper) {
arr[i] = upperSet;
}
}
return arr;
}
+629
View File
@@ -0,0 +1,629 @@
import { IdentityInt32Index, isLabelIndex } from "./labelIndex";
// weird cross-dependency that we should clean up someday...
import { sortArray } from "../typedCrossfilter/sort";
import { isTypedArray, isArrayOrTypedArray, callOnceLazy } from "./util";
import { summarizeContinuous, summarizeCategorical } from "./summarize";
/*
Dataframe is an immutable 2D matrix similiar to Python Pandas Dataframe,
but (currently) without all of the surrounding support functions.
Data is stored in column-major layout, and each column is monomorphic.
It supports:
* Relatively efficient creation, cloning and subsetting
* Very efficient columnar access (eg, sum down a column), and access
to the underlying column arrays.
* Data access by row/col offset or label. Labels are reasonably well
optimized for both numeric lables and arbitrary (eg, sting) labels.
It does not currently support:
* Views on matrix subset - for currently known access patterns,
it is more effiicent to copy on subsetting, optimizing for access
speed over memory use.
* JS iterators - they are too slow. Use explicit iteration over
offest or labels.
Important assumptions embedded in the API:
* Columns are implicitly categorical if they are a JS Array and numeric
(aka continuous) if they are a TypedArray.
There are three index types for row/col indexing:
* IdentityInt32Index - noop index, where the index label is the offset.
* KeyIndex - index arbitrary JS objects.
* DenseInt32Index - integer indexing. Optimization over KeyIndex as it uses
Int32Array as a back-map to offsets. This means that the index array
must be sized to [minLabel, maxLabel), so this is only useful when the label
range is relatively close the underlying offset range [minOffset, maxOffset).
All private functions/methods/fields are prefixed by '__', eg, __compile().
Don't use them outside of this file.
Simple example:
// default indexing is integer offset.
const df = Dataframe.create([2,2], [['a', 'b'], [0, 1]])
console.log(df.at(0,0)); // outputs: a
console.log(df.col(1).asArray()); // outputs: [0, 1]
// KeyIndex
const df = new Dataframe([1,2], [['a'], ['b']], null, new KeyIndex(['A', 'B']))
console.log(df.at(0, 'A')); // outputs: a
console.log(df.col('A').asArray(); // outputs: ['a']
Performance tuning is primarily focused on columnar access patterns, which is the
dominant pattern in cellxgene.
*/
/**
Dataframe
**/
class Dataframe {
/**
Constructors & factories
**/
constructor(
dims,
columnarData,
rowIndex = null,
colIndex = null,
__columnsAccessor = [] // private interface
) {
/*
The base constructor is relatively hard to use - as an alternative,
see factory methods and clone/slice, below.
Parameters:
* dims - 2D array describing intendend dimensionality: [nRows,nCols].
* columnarData - JS array, nCols in length, containing array
or TypedArray of length nRows.
* rowIndex/colIndex - null (create default index using offsets as key),
or a caller-provided index.
* __columnsAccessor - private interface, do not specify. Used internally
to improve caching of column accessors when possible (eg, clone(),
dropCol(), withCol()).
All columns and indices must have appropriate dimensionality.
*/
const [nRows, nCols] = dims;
if (nRows < 0 || nCols < 0) {
throw new RangeError("Dataframe dimensions must be positive");
}
if (!rowIndex) {
rowIndex = new IdentityInt32Index(nRows);
}
if (!colIndex) {
colIndex = new IdentityInt32Index(nCols);
}
Dataframe.__errorChecks(dims, columnarData, rowIndex, colIndex);
this.__columns = Array.from(columnarData);
this.dims = dims;
this.length = nRows; // convenience accessor for row dimension
this.rowIndex = rowIndex;
this.colIndex = colIndex;
this.__compile(__columnsAccessor);
}
static __errorChecks(dims, columnarData, rowIndex, colIndex) {
const [nRows, nCols] = dims;
/* check for expected types */
if (!Array.isArray(columnarData)) {
throw new TypeError("Dataframe constructor requires array of columns");
}
if (!columnarData.every(c => isArrayOrTypedArray(c))) {
throw new TypeError("Dataframe columns must all be Array or TypedArray");
}
if (!isLabelIndex(rowIndex)) {
throw new TypeError("Dataframe rowIndex is an unsupported type.");
}
if (!isLabelIndex(colIndex)) {
throw new TypeError("Dataframe colIndex is an unsupported type.");
}
/* check for expected dimensionality / size */
if (
nCols !== columnarData.length ||
!columnarData.every(c => c.length === nRows)
) {
throw new RangeError(
"Dataframe dimension does not match provided data shape"
);
}
if (nRows !== rowIndex.size()) {
throw new RangeError(
"Dataframe rowIndex must have same size as underlying data"
);
}
if (nCols !== colIndex.size()) {
throw new RangeError(
"Dataframe colIndex must have same size as underlying data"
);
}
}
static __compileColumn(column, getOffset, getLabel) {
/*
Each column accessor is a function which will lookup data by
index (ie, is equivalent to dataframe.get(row, col), where 'col'
is fixed.
In addition, each column accessor has several functions:
asArray() -- return the entire column as a native Array or TypedArray.
Crucially, this native array only supports label indexing.
Example:
const arr = df.col('a').asArray();
has(rlabel) -- return boolean indicating of the row label
is contained within the column. Example:
const isInColumn = df.col('a').includes(99)
For the default offset indexing, this is identical to:
const isInColumn = (99 > 0) && (99 < df.nRows);
ihas(roffset) -- same as has(), but accepts a row offset
instead of a row label.
indexOf(value) -- return the label (not offset) of the first instance of
'value' in the column. If you want the offset, just use the builtin JS
indexOf() function, available on both Array and TypedArray.
iget(offset) -- return the value at 'offset'
*/
const { length } = column;
/* get value by row label */
const get = function get(rlabel) {
return column[getOffset(rlabel)];
};
/* get value by row offset */
const iget = function iget(roffset) {
return column[roffset];
};
/* full column array access */
const asArray = function asArray() {
return column;
};
/* test for row label inclusion in column */
const has = function has(rlabel) {
const offset = getOffset(rlabel);
return offset >= 0 && offset < length;
};
const ihas = function ihas(offset) {
return offset >= 0 && offset < length;
};
/*
return first label (index) at which the value is found in this column,
or undefined if not found.
NOTE: not found return is DIFFERENT than the default Array.indexOf as
-1 is a plausible Dataframe row/col label.
*/
const indexOf = function indexOf(value) {
const offset = column.indexOf(value);
if (offset === -1) {
return undefined;
}
return getLabel(offset);
};
/*
Summarize the column data. Lazy eval;
*/
const summarize = callOnceLazy(() =>
isTypedArray(column)
? summarizeContinuous(column)
: summarizeCategorical(column)
);
get.summarize = summarize;
get.asArray = asArray;
get.has = has;
get.ihas = ihas;
get.indexOf = indexOf;
get.iget = iget;
return get;
}
__compile(accessors) {
/*
Compile data accessors for each column.
Use an existing accessor if provided, else compile a new one.
*/
const { getOffset, getLabel } = this.rowIndex;
this.__columnsAccessor = this.__columns.map((column, idx) => {
if (accessors[idx]) {
return accessors[idx];
}
return Dataframe.__compileColumn(column, getOffset, getLabel);
});
}
clone() {
/*
Clone this dataframe
*/
return new this.constructor(
this.dims,
[...this.__columns],
this.rowIndex,
this.colIndex,
[...this.__columnsAccessor]
);
}
withCol(label, colData, withRowIndex = null) {
/*
Create a new DF, which is `this` plus the new column. Example:
const newDf = df.withCol("foo", [1,2,3]);
Dimensionality of new column must match existing dataframe.
Special case: empty dataframe will accept any size column. Example:
const newDf = Dataframe.empty().withCol("foo", [1,2,3]);
If `withRowIndex` specified, the provided index will become the
rowIndex for the newly created dataframe. If not specified,
the rowIndex from `this` will be used (ie, the rowIndex is
unchanged).
*/
let dims;
let rowIndex;
if (this.isEmpty()) {
dims = [colData.length, 1];
rowIndex = null;
} else {
dims = [this.dims[0], this.dims[1] + 1];
({ rowIndex } = this);
}
if (withRowIndex) {
rowIndex = withRowIndex;
}
const columns = [...this.__columns];
columns.push(colData);
const colIndex = this.colIndex.withLabel(label);
const columnsAccessor = [...this.__columnsAccessor];
return new this.constructor(
dims,
columns,
rowIndex,
colIndex,
columnsAccessor
);
}
dropCol(label) {
/*
Create a new dataframe, omitting one columns.
const newDf = df.dropCol("colors");
*/
const dims = [this.dims[0], this.dims[1] - 1];
const coffset = this.colIndex.getOffset(label);
const columns = [...this.__columns];
columns.splice(coffset, 1);
const colIndex = this.colIndex.dropLabel(label);
const columnsAccessor = [...this.__columnsAccessor];
columnsAccessor.splice(coffset, 1);
return new this.constructor(
dims,
columns,
this.rowIndex,
colIndex,
columnsAccessor
);
}
static empty(rowIndex = null, colIndex = null) {
return new Dataframe([0, 0], [], rowIndex, colIndex);
}
static create(dims, columnarData) {
/*
Create a dataframe from raw columnar data. All column arrays
must have the same length. Identity indexing will be used.
Example:
const df = Dataframe.create([2,2], [new Uint32Array(2), new Float32Array(2)]);
*/
return new Dataframe(dims, columnarData, null, null);
}
__subset(rowOffsets, colOffsets, withRowIndex) {
const dims = [...this.dims];
const getSortedLabelAndOffsets = (offsets, index) => {
/*
Given offsets, return both offsets and associated lables,
sorted by offset.
*/
if (!offsets) {
return [null, null];
}
const sortedOffsets = sortArray(offsets);
const sortedLabels = new Array(sortedOffsets.length);
for (let i = 0, l = sortedOffsets.length; i < l; i += 1) {
sortedLabels[i] = index.getLabel(sortedOffsets[i]);
}
return [sortedLabels, sortedOffsets];
};
let { colIndex } = this;
if (colOffsets) {
let colLabels;
[colLabels, colOffsets] = getSortedLabelAndOffsets(
colOffsets,
this.colIndex
);
dims[1] = colOffsets.length;
colIndex = this.colIndex.subsetLabels(colLabels);
}
let { rowIndex } = this;
if (withRowIndex) rowIndex = withRowIndex;
if (rowOffsets) {
let rowLabels;
[rowLabels, rowOffsets] = getSortedLabelAndOffsets(
rowOffsets,
this.rowIndex
);
dims[0] = rowLabels.length;
if (!withRowIndex) rowIndex = this.rowIndex.subsetLabels(rowLabels);
}
/* subset columns */
let columns = this.__columns;
if (colOffsets) {
columns = new Array(colOffsets.length);
for (let i = 0, l = colOffsets.length; i < l; i += 1) {
columns[i] = this.__columns[colOffsets[i]];
}
}
/* subset rows */
if (rowOffsets) {
columns = columns.map(col => {
const newCol = new col.constructor(rowOffsets.length);
for (let i = 0, l = rowOffsets.length; i < l; i += 1) {
newCol[i] = col[rowOffsets[i]];
}
return newCol;
});
}
return new Dataframe(dims, columns, rowIndex, colIndex);
}
subset(rowLabels, colLabels = null, withRowIndex = null) {
/*
Subset by row/col labels.
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
const toOffsets = (labels, index) => {
if (!labels) {
return null;
}
return labels.map(label => {
const off = index.getOffset(label);
if (off === undefined) {
throw new RangeError(`unknown label: ${label}`);
}
return off;
});
};
const rowOffsets = toOffsets(rowLabels, this.rowIndex);
const colOffsets = toOffsets(colLabels, this.colIndex);
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
isubset(rowOffsets, colOffsets = null, withRowIndex = null) {
/*
Subset by row/col offset.
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
isubsetMask(rowMask, colMask = null, withRowIndex = null) {
/*
Subset on row/column based upon a truthy/falsey array (a mask).
withRowIndex allows assignment of new row index during subset operation.
If withRowIndex === null, it will reset the index to identity (offset)
indexing. if withRowIndex is a label index object, it will be used
for the new dataframe.
*/
const [nRows, nCols] = this.dims;
if (
(rowMask && rowMask.length !== nRows) ||
(colMask && colMask.length !== nCols)
) {
throw new RangeError("boolean arrays must match row/col dimensions");
}
/* convert masks to lists - method wastes space, but is fast */
const toList = (mask, maxSize) => {
if (!mask) {
return null;
}
const list = new Int32Array(maxSize);
let elems = 0;
for (let i = 0, l = mask.length; i < l; i += 1) {
if (mask[i]) {
list[elems] = i;
elems += 1;
}
}
return new Int32Array(list.buffer, 0, elems);
};
const rowOffsets = toList(rowMask, nRows);
const colOffsets = toList(colMask, nCols);
return this.__subset(rowOffsets, colOffsets, withRowIndex);
}
/**
Data access with row/col.
**/
col(columnLabel) {
/*
Return accessor bound to a column. Allows random row access
based upon the row indexing. Returns undefined if the
columnLabel is not present in the dataframe.
Example for a dataframe with string labeled columns, and
default (offset) indices for rows (eg, [0, 'foo'])
const getValue = df.col('foo');
for (let r = 0; r < df.nRows; r += 1) {
console.log(r, getValue(r));
}
See __compile() for the functions available in a column accessor.
*/
const coff = this.colIndex.getOffset(columnLabel);
return this.__columnsAccessor[coff];
}
icol(columnOffset) {
/*
Return column accessor by offset.
*/
return this.__columnsAccessor[columnOffset];
}
at(r, c) {
/*
Access a single value, for a row/col label pair.
For performance reasons, there are no bounds or existance
checks on labels, and no defined behavior when these are supplied.
May return undefined, throw an Error, or do something else for
non-existant labels. If you want predictable out-of-bounds
behavior, use has(), eg,
const myVal = df.has(r,l) ? df.at(r,l) : undefined;
*/
const coff = this.colIndex.getOffset(c);
const roff = this.rowIndex.getOffset(r);
return this.__columns[coff][roff];
}
iat(r, c) {
/*
Access a single value, for a row/col offset (integer) position.
For performance reasons, there are no bounds checks on row/col offsets
or other well-defined behavior for out-of-bounds values. If you want
well-defined bounds checking, use ihas(), eg,
const myVal = df.ihas(r, c) ? df.iat(r, c) : undefined;
*/
return this.__columns[c][r];
}
has(r, c) {
/*
Test if row/col labels exist in the dataframe - returns true/false
*/
const [nRows, nCols] = this.dims;
const coff = this.colIndex.getOffset(c);
const roff = this.rowIndex.getOffset(r);
return coff >= 0 && coff < nCols && roff >= 0 && roff < nRows;
}
ihas(r, c) {
/*
Test if row/col offset (integer) position exists in the
dataframe - returns true/false
*/
const [nRows, nCols] = this.dims;
return c >= 0 && c < nCols && r >= 0 && r < nRows;
}
hasCol(c) {
/*
Test if col label exists - return true/false
*/
return !!this.col(c);
}
isEmpty() {
/*
Return true if this is an empty dataframe, ie, has dimensions [0,0]
*/
const [rows, cols] = this.dims;
return rows === 0 && cols === 0;
}
/****
Functional (map/reduce/etc) data access
TODO: most are not yet implemented, as there is no clear use case. Can easily
add these as useful.
****/
mapColumns(callback) {
/*
map all columns in the dataframe, returning a new dataframe comprised of the
return values, with the same index as the original dataframe.
callback MUST not modify the column, but instead return a mutated copy.
*/
const columns = this.__columns.map(callback);
const columnsAccessor = columns.map((c, idx) =>
this.__columns[idx] === c ? this.__columnsAccessor[idx] : undefined
);
return new this.constructor(
this.dims,
columns,
this.rowIndex,
this.colIndex,
columnsAccessor
);
}
/*
Map & reduce of column or row
TODO remainder of map/reduce functions: mapCol, mapRow, reduceRow, ...
*/
/* comment out until we have a use for this
reduceCol(clabel, callback, initialValue) {
const coff = this.colIndex.getOffset(clabel);
const column = this.__columns[coff];
let start = 0;
let acc = initialValue;
if (initialValue === undefined) {
acc = column[0];
start = 1;
}
for (let i = start, l = column.length; i < l; i += 1) {
acc = callback(acc, column[i]);
}
return acc;
}
*/
}
export default Dataframe;

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