Compare commits

...
Author SHA1 Message Date
Timmy Huang afb4ba906b server update 2024-01-24 10:16:38 -08:00
Timmy Huang ede95bfe4b update requirements.txt 2024-01-23 10:56:56 -08:00
Timmy Huang 7d61f47a31 updated visium branch 2024-01-23 10:48:27 -08:00
Emanuele Bezzi 11690eb745 Add README-visium 2021-12-22 16:41:11 -05:00
Emanuele Bezzi eac84c5d74 Clean comments and logs 2021-12-21 11:33:38 -05:00
Emanuele Bezzi cbf3ba240a Put scaling back in the backend 2021-12-09 14:12:16 -05:00
Emanuele Bezzi 2fe9cc4aac Small fix 2021-12-08 17:20:06 -05:00
Emanuele Bezzi 8b07e57257 Connect button 2021-12-08 16:37:22 -05:00
Colin Megill f4c4ac5bda undable config and conditional graph render of image 2021-12-08 11:54:22 -08:00
Emanuele Bezzi febf582a0b Merge branch 'visium-beta' of github.com:chanzuckerberg/cellxgene into visium-beta 2021-12-07 19:42:53 -05:00
Emanuele Bezzi efe3bf7a72 Parametrization 2021-12-07 19:42:40 -05:00
Colin Megill b411fca5a3 auto switch spatial off 2021-12-07 16:32:55 -08:00
Colin Megill caa1526eb6 intent 2021-12-07 15:59:05 -08:00
Colin Megill 99c8f37a60 button, reducer state 2021-12-07 15:53:56 -08:00
Emanuele Bezzi b048bbfd0c Checkpoint 2021-12-06 14:57:10 -05:00
Emanuele Bezzi b1ff638879 Checkpoint 2021-12-05 12:58:21 -05:00
Emanuele Bezzi db0f50d011 Add frontend 2021-12-01 16:49:13 -05:00
Emanuele Bezzi 54d4de431c Add backend endpoint 2021-12-01 12:02:45 -05:00
Severiano Badajoz 853976b716 release version 1.0.0 (#2466)
* Bump version: 0.19.0 → 1.0.0-rc.0

* Bump version: 1.0.0-rc.0 → 1.0.0
2021-11-02 14:53:26 -07:00
Emanuele Bezzi a344f292a9 Restore logo and favicon in docs/ (#2464) 2021-10-14 21:13:04 +00:00
Emanuele Bezzi 15d2ce62b8 Migrate to the new documentation website (#2456) 2021-10-14 20:54:54 +00:00
Colin Megill 8e011314e9 Remove log (#2463) 2021-10-14 18:02:54 +00:00
Ben MR c81731d32c [FE: fix #2371] fix for when labels are null (#2462)
Categoricals can have nulls
2021-10-05 12:17:34 -07:00
Colin Megill 3b091d888d Update diffexpButtons.js (#2461) 2021-10-04 15:35:55 -07:00
Timmy Huang 79aeedc6c3 refactor: 2235 remove cookie banner (#2459) 2021-10-04 11:26:01 -07:00
Colin Megill e6bacf043b typo fix (#2460) 2021-10-01 18:28:47 -07:00
Colin Megill be020deaa6 Update infoMenu.js (#2455)
Fixes #2452
2021-09-29 13:00:55 -07:00
Emanuele Bezzi 4034256d9c Create CNAME file to point docs to the new website (#2453)
Add a CNAME file that will redirect the current github pages (hosted at https://chanzuckerberg.github.io/cellxgene/) to the new website.
2021-09-29 13:28:49 -04:00
Ben MR 5163e35e1f fix #2446 update release docs and commands (#2448)
* update release docs and commands

* update doc on release-test install
2021-09-29 15:06:35 +00:00
Ben MR f4986c9c7d Release version 0.19.0 (#2444)
* Bump version: 0.18.0 → 0.19.0-rc.0

* release can 0.19.0

* Bump version: 0.19.0-rc.0 → 0.19.0-rc.1

* bump rc

* Bump version: 0.19.0-rc.1 → 0.19.0

* release final
2021-09-28 11:10:24 -07:00
Colin Megill a0011b0872 cursor pointer on mit license and version (#2447) 2021-09-27 09:47:34 -07:00
Colin Megill 77fff0bd9c Remove pointer on gene (#2445)
* Remove grab

Fixes #2438

* snapshot

* pointer

* snapshot
2021-09-24 12:21:32 -07:00
Ben MR 6a4884d97c Re-add bumpversion to makefile (#2443) 2021-09-23 22:08:00 +00:00
Colin Megill 15714e9924 Remove grab (#2439)
* Remove grab

Fixes #2438

* snapshot
2021-09-23 10:30:55 -07:00
Severiano Badajoz 5eb0ecc997 chore: update frontend packages (#2442)
* run audit

* migrate husky
2021-09-22 10:22:44 -07:00
Andrew Tolopko e2772d1da9 #2410 reenable scheduled compatibility test (#2440)
re-enable scheduled compat matrix tests on main branch; was temporarily commented out for testing on branch
2021-09-21 17:27:45 -04:00
Colin Megill 7f068cd576 Reverse gene list order (#2432)
Fixes #2420
2021-09-21 11:53:50 -07:00
jacobrheath e4961a579e feat: removing sastisfaction gh action for gh app (#2441) 2021-09-21 10:09:30 -07:00
Andrew Tolopko c0263f6ce5 #2410 update compatibility test matrix (#2431)
Github Actions Workflow updates compatibility matrix:
- Added MacOS Catalina and Big Sur to test compat matrix
- Added Python 3.9 to test compat matrix, but avoid running 3.9 for matrix jobs that do not have `tables` pypi build available for the given env
- Maintains running tests on both cellxgene main branch and latest pypi release.
- Add explicit matrix exclusions for matrix combinations that will never pass (see comments).
- Numerous refactorings to the workflow config to simplify matrix. Basically a rewrite.
- The anndata pkg is now tested at a pinned release and at latest release, but no longer using `master` branch version. To limit cross-product explosion of matrix jobs, the pinned anndata version is only tested on py3.8 and cellxgene latest release.
- Run unit and smoke tests in a single job, to improve speed, reduce workflow complexity and the number of jobs. Also fixes the redundant testing of unit tests. Within each job, the unit and smoke tests are run in separate steps for ease of troubleshooting. 
- Fixed termination of backend server to allow both smoke tests to run within a single job (both attempt to use 5005 port, sequentially, but first server was not being terminated).
- Replaced `continue-on-error: true` with `fail-fast: false`, which allows all matrix jobs to run independently, while also ensuring the that entire workflow is flagged as failed if any matrix job fails
-  The `smoke-test-annotations` fail intermittently and have been disabled. Fix will be addressed in story: https://app.zenhub.com/workspaces/single-cell-5e2a191dad828d52cc78b028/issues/chanzuckerberg/cellxgene/2433
2021-09-21 08:32:19 -04:00
Madison Dunitz 3ebbb0ccbf move common code into server, update tests and makefile (#2425)
* move common code into server, update tests and makefile

remove backend directory, refactor

update smoke tests
2021-09-20 18:50:06 -07:00
Ben MR 97caa5bcaa Upgrade min versions of dependencies [zh2426] (#2436)
* Update dependencies

Flask to 2.0
Scipy etc. bumped to latest version not supporting 3.5
Others bumped to latest where possible

* py min v

* relax py version

* revert reqs changes
2021-09-20 17:30:30 -07:00
Severiano Badajoz f49c3d8fe7 feat: remove info drawer and reintroduce singleton categories (#2421)
* refactor: drop non-session auth from frontend

* remove dataset drawer and reintroduce singleton values
2021-09-20 16:58:55 +00:00
Ben MR ef2ab07ca0 Clean up dead/hosted code [zh2310] (#2430)
* Clean up dead/hosted code

* Remove schema conversion tool and related
* Remove cxg references
* Remove locust

* missed a spot

* Remove aws secret manager

* Merge branch 'main' into brodgers/2310/code-cleanup-v1

* cleanup merge
2021-09-17 20:41:12 +00:00
Ben MR 69e159916e [zh2311] Remove auth (#2427)
* remove auth

* Remove all auth code

zh2311

* lint

* remove auth from e2e tests conf
2021-09-17 18:47:52 +00:00
Severiano Badajoz a239d8636d fix: more accurate validation on geneset name (#2429) 2021-09-15 16:40:34 -07:00
Severiano Badajoz 0634160c0c Fix unit tests (#2428)
* only create toaster if we're on the browser

* drop/fix references to window

* add back fe unit tests
2021-09-15 00:43:59 +00:00
Severiano Badajoz 01013bcf04 refactor: drop non-session auth from frontend (#2419) 2021-09-14 15:55:46 -07:00
Madison Dunitz f9c744327a Dunitz/2308-remove hosted (#2399)
* remove code for and references to czi-hosted

* revert client change
2021-09-02 13:18:35 -04:00
Severiano Badajoz 780852fd49 docs: release 0.18.0 (#2413)
* Bump version: 0.17.0 → 0.18.0-rc.0

* Bump version: 0.18.0-rc.0 → 0.18.0
2021-08-31 15:52:30 -07:00
Colin Megill de03129061 Spinner on color by geneset when closed (#2412)
* take 2

* take 2.1

* bruce test

* remove component did mount

* working
2021-08-27 12:15:47 -07:00
Severiano Badajoz 2715dba703 fix: update/remove deprecated packages (#2411)
* replace deprecated packages

* more updates
2021-08-26 15:18:43 -07:00
Bruce Martin 154d099fef X float16 support (#2406)
* float16 support

* fix type checks

* PR review comments

* add tests for custom json encoder; rename and comment for posterity

* lint

* typos
2021-08-25 11:12:49 -07:00
eaae6df5e3 TS Revert (1) (#2402)
* revert all commits to before Typescript migration

* update compat workflow to match latest deps (#2335)

* update compat workflow to match latest deps

* attempt to debug

* attempt to debug

* remove debugging code

* typo

* update deps to match desktop (#2340)

* fix: don't run lint with `--fix` on push tests (#2273)

* fix: don't run lint with `--fix` on push tests

* npx

Co-authored-by: maniarathi <mani.arathi@gmail.com>
Co-authored-by: Madison Dunitz <madison.dunitz@chanzuckerberg.com>

* rename X_approx_distribution to X_approximate_distribution (#2337)

* Correctly handle non-finite numbers in heuristic determination of X distribution (#2342)

* handle non-finites explicitly

* improve and test edge case handling for distribution estimation

* revert debugging changes

* code readability

* clean up type inferencing (#2332)

* unit tests for 64 bit conversion

* clean up type handling

* type inference tests

* more type inference fixes

* use schema to determine user intent for data typing

* stop using deprecated API

* fbs type encoding test

* add missing test

* add more tests

* correctly infer X type for CXG adaptor

* lint

* fix typo

* ts migration

* cleanup from PR review

* lint

* PR review changes

* remove unused packages from client (#2359)

* remove unused packages from client

* add missing peer dep

* fix: disable FE auth testing on compatibility tests (#2377)

* update: release process (#2277)

Co-authored-by: maniarathi <mani.arathi@gmail.com>

* fix: remove spaces in param setup (#2380)

* delete deploy workflow (#2396)

* undo reformatting which now does not pass lint

* fix snapshots which changed due to npm dep changes

* add missing quoting to snapshot

* another snapshot typo fix

* TS Revert (2) - replay PR #2347 and #2354 (#2403)

* replay edits from PR 2347

* TS Revert (3) - replay edits in PR #2327 (#2404)

* replay edits in PR 2327

* TS Revert (4) - replay PR #2355 (#2405)

* replay edits in PR 2355

* add additional babel config

* reformat with new prettier config

Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
Co-authored-by: maniarathi <mani.arathi@gmail.com>
Co-authored-by: Madison Dunitz <madison.dunitz@chanzuckerberg.com>
2021-08-23 15:01:36 -07:00
Madison Dunitz 295590a7c6 delete deploy workflow (#2396) 2021-08-18 17:13:04 -05:00
Mim HastieandTimmy Huang b814489328 Added typings to annoMatrix dir. (#2365) (#2371)
* Added typings to annoMatrix directory. (#2365)

* Added review point updates. (#2365)

* Fixed viewOf defect. Added review point updates. (#2365)

* Updated test to use Field key interpolation. (#2365)

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

* Updated test to use Field key interpolation. (#2365)

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

* Updated test to use Field key interpolation. (#2365)

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

* Updated test to use Field key interpolation. (#2365)

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

* Linting. (#2365)

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

* Simplified while condition in middleware GC. (#2365)

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

* Reverted embedding.ts prettier to resolve conflict. (#2365)

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>
2021-08-18 13:18:54 -07:00
Severiano BadajozandTimmy Huang 08b03ace60 chore: type general utils (#2381)
* type camera

* type reducer store

* type actionhelpers

* type catchErrorsWrap callsite

* missed camera member var

* type nameCreators

* type makeContinousDimensionName callsite

* type promise limit

* type quantile

* type range

* introduce TypedArray + NumericArray

* type range

* cleanup test

* fix call sites

* type plimit call site

* finish typing camera

* use our TypedArray

* type scientific and sigFig utils and callsites

* simple typings

* type catLabelSort

* type callsite

* type

* callsites

* type camera methods

* swap back to strings, set defaults accordingly

* partially type centroid

* explicit tuple and undefined check

* fix references to this

* call constructor with new and casting

* Revert "introduce TypedArray + NumericArray"

This reverts commit cf21538717.

* explicit tuple

* generics and import fixes

* add unsigned 8 clamped arrray

* back to literals

* use arraytypes

* fix return state

* type more actions

* Update client/src/util/actionHelpers.ts

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

* properly type dispatch

* properly type thunk

* use new dispatch

* remove nullish coallescer

* use AppDispatch

* generic jsonrequest

* use dispatch again

* lint

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>
2021-08-18 00:15:53 +00:00
Bruce Martin 45cecad76a TS typing for Dataframe (#2382)
* initial TS typing

* first cut at Dataframe TS typing

* more Dataframe typing

* comments

* more Dataframe cleanup

* PR review fixes and improvements
2021-08-17 10:43:40 -07:00
Severiano Badajoz 3fdf5cac9d fix: remove spaces in param setup (#2380) 2021-08-13 17:01:10 +00:00
Severiano Badajozandmaniarathi 3c3a794986 update: release process (#2277)
Co-authored-by: maniarathi <mani.arathi@gmail.com>
2021-08-13 00:25:58 +00:00
Bruce Martin 4b417cb5a5 undoable TS typing (#2374)
* type undoable-related TS

* style change to type declaration
2021-08-12 17:14:09 -07:00
Severiano Badajoz 925b785b1f fix: disable FE auth testing on compatibility tests (#2377) 2021-08-12 18:34:04 +00:00
Bruce Martin 660dff256c add array type foundations (#2376) 2021-08-12 07:17:05 -07:00
Timmy Huang 59c475b821 chore: extract schema types (#2375)
* chore: extract schema types

* address comments
2021-08-12 03:12:21 +00:00
Timmy Huang fc60b2acef fix: thuang-fix-tsconfig-path (#2372)
Thanks so much for the quick review, Bruce!!
2021-08-11 01:38:33 +00:00
Timmy Huang 26de334274 chore: add schema types (#2369) 2021-08-04 13:16:18 -07:00
Timmy Huang 95ce39f2e9 chore: Add global type file (#2363) 2021-08-03 21:52:10 +00:00
Bruce Martin 03bb904f24 remove unused packages from client (#2359)
* remove unused packages from client

* add missing peer dep
2021-07-30 20:00:00 -07:00
Colin Megill 01d34580b9 genesets e2e tests, undo/redo (#2327)
* undo redo create

* edit undo redo

* all tests pass, add, edit

* description

* remove RER1

* remove rer1

* remove from hosted
2021-07-30 16:52:49 -07:00
Timmy Huangandbkmartinjr 5ab96ed360 disable formatting rules for eslint and add prettier in lint-staged (#2355)
* disable formatting rules for eslint and add prettier in lint-staged

* update npm modules

* set plugin-proposal-private-methods to loose

* update snapshots due to popover package update

* add missing quotes

Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
2021-07-30 12:27:47 -07:00
Bruce Martin 97fb98b4eb API update for tests (#2354) 2021-07-29 20:27:29 -07:00
Bruce Martin 0e7daea737 temp fixes for TS lint (#2352) 2021-07-29 18:31:38 -07:00
Bruce Martin 8136387127 Clean up max-category front-end limit (#2347)
* remove topN category truncation from component rendering layer

* clean up category item limit implementation

* name change for clarity

* fix snapshot

* comments
2021-07-29 16:05:10 -07:00
Mim HastieandTimmy Huang 27575b8d86 Added @typescript-eslint/recommended config with suppressions (#2345)
* Disabled @blueprintjs/classes-constants. #2288.

* thuang-eslint-bp-off (#2344)

* Disabled @blueprintjs/classes-constants on webpack dev and shared. #2288.

* Added TS recommended, suppress lint errors codemod.

* Added per-error/warning ignore for tests.

* Added per-error/warning ignore for configuration.

* Added per-error/warning ignore for src. Removed suppress package.

* Minor linting.

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>
2021-07-28 16:06:34 -07:00
Bruce Martin 32f60a1547 clean up type inferencing (#2332)
* unit tests for 64 bit conversion

* clean up type handling

* type inference tests

* more type inference fixes

* use schema to determine user intent for data typing

* stop using deprecated API

* fbs type encoding test

* add missing test

* add more tests

* correctly infer X type for CXG adaptor

* lint

* fix typo

* ts migration

* cleanup from PR review

* lint

* PR review changes
2021-07-28 15:10:12 -07:00
Bruce Martin 1140676106 Correctly handle non-finite numbers in heuristic determination of X distribution (#2342)
* handle non-finites explicitly

* improve and test edge case handling for distribution estimation

* revert debugging changes

* code readability
2021-07-28 14:34:29 -07:00
Bruce Martin 0b1ab02a60 rename X_approx_distribution to X_approximate_distribution (#2337) 2021-07-27 13:43:04 -07:00
1998c0ad63 fix: don't run lint with --fix on push tests (#2273)
* fix: don't run lint with `--fix` on push tests

* npx

Co-authored-by: maniarathi <mani.arathi@gmail.com>
Co-authored-by: Madison Dunitz <madison.dunitz@chanzuckerberg.com>
2021-07-27 16:43:01 +00:00
Bruce Martin 2b072e6271 update deps to match desktop (#2340) 2021-07-27 08:29:00 -07:00
Bruce Martin a1c46170b9 update compat workflow to match latest deps (#2335)
* update compat workflow to match latest deps

* attempt to debug

* attempt to debug

* remove debugging code

* typo
2021-07-26 14:32:46 -07:00
934cc5c69b TS migration. #2288. (#2328)
* Added TS. Updated build and linting config. Added types.

* [ts-migrate][.] Rename files from JS/JSX to TS/TSX

Co-authored-by: ts-migrate <>

* [ts-migrate][.] Run TS Migrate

Co-authored-by: ts-migrate <>

* Corrected files mangled by ts-migrate.

* Updated lint config, minor linting.

* Re-enabled Husky.

* Updated tests and config.

* Reverted webpack devtool config.

* Removed obsolete snapshots.

* Added annotations snap.

* Updated tsconfig includes wrt linting.

* Removed ts-migrate.

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>
2021-07-26 20:18:17 +00:00
jacobrheath 7328cbdbd5 feat[sastisfaction]: adding non-blocking security linting to cellxgene (#2210)
* adding sastisfaction

* Update sastisfaction.yml

* Update sastisfaction.yml
2021-07-26 12:24:43 -07:00
Bruce Martin 1ea2b7fe80 fix for incorrect stats computation in diff exp t-test (#2318)
* 2211 fixes

* lint

* lint

* add missing test and bug found by test

* change terminology for count distribution

* update scanpy requirement

* update scanpy requirement
2021-07-23 11:36:26 -07:00
Severiano Badajoz 1ebde2213d fix: set count to 15 for testing (#2324) 2021-07-21 22:56:22 +00:00
Severiano Badajoz bbf1950624 fix: decrease the topN count explicitly on hosted (#2320)
* fix: decrease the topN count explicitly on hosted

* lint
2021-07-21 18:12:10 +00:00
Bruce Martin 3d7490e0a9 gene expression perf work (#2305)
* gene expression perf work

* lint
2021-07-16 12:56:42 -07:00
Bruce Martin 0667ad0274 remove experimental reembedding support (#2301)
* remove experimental reembedding support

* lint

* lint

* add prepare requirements to requirements-dev

* oops, revert accidental deletion of import

* more test modifications

* remove obsolete unit tests
2021-07-15 13:55:26 -07:00
Bruce Martin e334fbe96e remove experimental ontology support (#2300)
* remove experimental ontology support

* lint

* remove ontologies from unit tests

* additional test changes
2021-07-14 07:23:38 -07:00
maniarathi 45a8984223 Update license to be 2021. (#2285) 2021-07-12 10:00:17 -07:00
Severiano Badajoz 07816c2f93 docs: release 0.17.0 (#2278)
* Bump version: 0.16.7 → 0.17.0-rc.0

* Bump version: 0.17.0-rc.0 → 0.17.0
2021-07-07 15:07:37 -07:00
Severiano Badajoz 90da04b6c7 fix: update server version to 0.16.7 (#2276) 2021-07-06 23:25:34 +00:00
Severiano Badajoz 873b3a2f1f fix: point to local server (#2274) 2021-07-06 16:07:17 -07:00
Severiano Badajoz 5f976cc4fc fix: convert sparse numpy matrix to ndarray (#2271) 2021-07-06 21:04:50 +00:00
signechambers1 f55c726e2a Adding gene sets documentation to cellxgene docs (#2259)
* Create gene_sets.md

* Add files via upload

* Update gene_sets.md

user guide updates

* Update gene_sets.md

Updates to multi-user

* Update gene_sets_example.csv

* Update gene_sets.md
2021-07-06 10:30:54 -10:00
signechambers1andSeve Badajoz 56fcbae672 Remove safari from supported browsers (#2272)
* Update README.md

* Update package.json

* Update obsoleteHTMLTemplate.html

* remove unneeded polyfills

* explicit deny safari

* remove from webpack and update lock

Co-authored-by: Seve Badajoz <sbadajoz@chanzuckerberg.com>
2021-07-06 10:09:51 -10:00
face1b3033 genesets e2e tests (#2241)
* __test: create geneset

* example dataset test geneset

* delete geneset test

* edit __test

* gene crud

* Update client/Makefile

Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>

* copy gene sets separately

* make fix

* ignore test files locally

* csv update

* updated csvs

* fix unit tests for gene set load routes

* add missing fix to czi_hosted unit test

* pin tiledb version, for czi_hosted backend, to <0.9

* Revert tiledb pin to be less than 0.9. Broken tests have been updated in main branch.

* newline, gitignore

* color by and subset

* diffexp sets equal

* add diff exp test class

* fix data class

* diffexp snapshot

* snapshot

* snap3

* snapshot parentInnerhtml

* remove snap

* updated anno snaps

* add test class to gene list div

* new snapshots

* kick off

* Revert "kick off"

This reverts commit 743f551d55.

* remove import

* eol

* revert changes to csv re: gene tests

* global name

Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
Co-authored-by: Arathi Mani <arathi.mani@chanzuckerberg.com>
Co-authored-by: maniarathi <mani.arathi@gmail.com>
2021-07-01 21:29:06 -04:00
Severiano Badajoz b714c18e75 feat: frontend geneset validation (#2258)
* add geneset name validation

* validate genes before addition

* display error messages
2021-07-01 22:09:50 +00:00
Severiano Badajoz b8b1d0dd9e fix: reset gene set colorScale on gene set deletion (#2264) 2021-06-30 16:46:44 -07:00
Madison Dunitz 5007d307a2 Dunitz/czi hosted test server (#2254)
update hosted unit tests to use wsgi server instead of cellxgene packages
2021-06-30 15:07:54 -07:00
Severiano Badajoz 82de4178d9 feat: add quick gene lookup functionality (#2250)
* add/remove gene functionality back with geneset style gene

* styling and expansion

* memo gene list to prevent re render
2021-06-23 13:59:10 -04:00
maniarathi 023ae10822 Update unit tests for CXG conversion to check for actual content rather than file names alone which have changed with the recent 0.9 release of tiledb's python package. (#2249)
* Update unit tests for CXG conversion to check for actual content rather than file names alone which have changed with the recent 0.9 release of tiledb's python package.

* Some cleanup

* Undo a bad line
2021-06-19 09:37:05 -07:00
28b526b3fc feat: diffexp returns two genesets (#2230)
* feat: return two lists for diffexp (#2221)

* sp

* split out derive sort order, tests passing

* sp

* return diff exp results in two lists

* update

* copy implementation over to desktop

* add tests for two lists

* small fixes to complete backend implementation

* accept new diffexp response

* map diff exp response to genesets

* delete )

* name diffexp genesets with population names

* take constants out of state and allow width prop to override

* shorten mini-histo properly truncate and resize depending on expansion

* prepend new genesets

* rename data within diffexp action

* backend

* move diffexp ttest to common code module, update tests

* update for unit tests

* reference actual var

Co-authored-by: Madison Dunitz <madison.dunitz@chanzuckerberg.com>
Co-authored-by: Madison Dunitz <dunitzm@gmail.com>
2021-06-08 21:02:19 +00:00
Severiano Badajoz 7ed53c0f5b fix: add placeholder test so suite doesn't fail (#2233)
* comment out test suite

* actually just put in placeholdertest
2021-05-26 12:41:16 -07:00
Severiano Badajoz a4050f10e1 feat(geneset): save diff exp result as geneset (#2216)
* update package lock

* allow falsey vals

* remove old diff exp handling

* save diff exp results as geneset

* delete test

* check for undefined or null

* use global geneset description for diffexp

* remove diffexp special code, no longer showing adjpval + logfoldchange

* remove differential map to state

* remove clear from FSM, since we no longer support those actions

* restore controlHelpers test with todo
2021-05-25 10:51:31 -07:00
Madison Dunitz 6f6634a4d9 add action to deploy on on push to canary branch (#2196)
* add action to deploy on on push to canary branch
2021-05-17 16:15:48 -07:00
Severiano Badajoz 265ccf3682 fix: use env specific secret (#2204) 2021-05-13 14:28:16 -07:00
maniarathi 59ec3afbb9 Pin versions of flatbuffer and Flask to be less than 2.0 (#2199) 2021-05-12 15:13:10 -07:00
Madison Dunitz 4666f1f044 add rule for static assest without dataset id (#2194) 2021-05-07 21:17:31 -07:00
Colin Megillandbkmartinjr d04dba225f Filename dialogue includes gene sets (#2185)
* change filenames

* gene-sets to anno dialogue

* logging, lambda

* celllabels

* cell dash labels

* space

* fix 2182 - annotation file name change on the backend

Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
2021-05-07 17:32:11 -04:00
Colin Megill 096d8ff1d2 Geneset remove toggle (#2184)
* no toggle, histo at top

* set mean expression, truncation

* gene set
2021-05-05 19:51:05 -04:00
Colin Megill 25b308c532 conditional colon (#2181) 2021-05-04 13:43:02 -04:00
Bruce Martin f2e9aecebe hosted gene sets routes, plus a few bug fixes (#2155)
* first cut at hosted gs routes

* lint

* update tests to match csv parser changes

* update tests to new API

* update gene set name validation rules to match requirements

* add path mapping from dataset to geneset

* add test cases for geneset GET route

* fix test assertion

* remove debugging code

* update gene set uri mapping function

* fix error message

* allow extra user-specified headers in gene set csv file

* clarify comment
2021-04-27 13:58:58 -07:00
maniarathi ebeb1c8818 Fix command to EB to use the artifact.zip file during deployment instead of the source code in the directory. (#2174) 2021-04-23 11:07:57 -07:00
maniarathi b60d20eb2f Fix phony (#2173) 2021-04-22 18:00:00 -07:00
Colin Megill fd2a7a53ab Color by gene set mean expression (#2157)
* colorby histo

* color graph by mean expression

* move var index after returns

* add genesets as an argument

* varindex

* undo redo for mean expression

* destructure

* ternary

* Revert "destructure"

This reverts commit 2d9432c1c7.

* color by mean for diffexp
2021-04-22 13:58:30 -04:00
Bruce Martin 860547ced1 update requirements for server (#2172) 2021-04-21 17:12:16 -07:00
Colin Megillandbkmartinjr 876ceb4d8b Create / edit geneset description (#2139)
* geneset description add

* edit geneset description

* default state for desc

* remove log

* naming, todo

* check for both dup name and desc

* fixes

* do not store gene set modal state in history stack

* Update createGenesetDialogue.js

* Update editGenesetNameDialogue.js

Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
2021-04-21 19:50:29 -04:00
Bruce Martin 99a795a688 Updating front-end dependencies (#2167)
* update to webpack 5

* update babel

* update eslint

* update cheerio

* update npm min to v7

* revert engine change

* generate package lock with npm v6 (lockfileVersion 1)

* add region to test setup

* update blueprint popover2

* tabindex changes due to blueprint popover2 revision

* update snapshots

* update lodash and pako

* fix typo

* fix lodash refactoring

* more lodash refactoring

* update babel and blueprintjs

* update jest support packages

* update puppeteer

* update regl

* update react-icons and react-helmet

* update react and react-dom
2021-04-21 07:23:31 -07:00
maniarathiandTimmy Huang e2ce9a90ca Remove updates coming from cookie banner since updates have already been executed. (#2161)
Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>
2021-04-19 08:42:34 -07:00
Madison Dunitz 63cf82c60e Dunitz/scale test update (#2159) 2021-04-18 13:33:04 -05:00
Colin Megill c23b25d4e8 geneset description (#2141) 2021-04-12 16:31:21 -04:00
Severiano Badajoz f36d889455 fix: explicitly depend on favicons (#2143) 2021-04-08 15:55:00 -07:00
Severiano Badajoz 4510c8c8a4 Fetch passwords from secretsmanager (#2138)
* add aws secretsmanagerclient

* create custom globalsetup

* consume secret password and enable tests

* update npm
2021-04-05 14:16:10 -07:00
Colin MegillandAmbrose J Carr 6ecdfa4940 Create design_principles.md (#1903)
* Create design_principles.md

* Update design_principles.md

* Update design_principles.md

* Update design_principles.md

* Update design_principles.md

* Update design_principles.md

* Update dev_docs/design_principles.md

Co-authored-by: Ambrose J Carr <ambrosejcarr@users.noreply.github.com>

* Update dev_docs/design_principles.md

Co-authored-by: Ambrose J Carr <ambrosejcarr@users.noreply.github.com>

Co-authored-by: Ambrose J Carr <ambrosejcarr@users.noreply.github.com>
2021-04-01 16:50:09 -04:00
Colin Megill b446bf7144 Handle empty gene set (#2128)
* handle empty gene set case

* merge error, remove conflict markers

* paren
2021-04-01 13:45:47 -04:00
Colin Megill 20bfa4cc97 prepopulate input (#2129) 2021-04-01 06:21:59 -04:00
Bruce Martin 2fa19c756c propagate diffexp state through component tree (#2135) 2021-03-31 12:23:38 -07:00
Bruce Martin ae30b66123 gene set summary progress (#2127)
* revert removal of cache control headers

* checkpoint work on revising summary route

* add summary query support to annoMatrix

* summarize route cleanup

* add mising file

* clean up summarize route

* add summary histogram

* update deps

* lint

* more lint

* lint

* manage crossfiler during gene set state changes

* remove obsolete debugging code

* correctly perform async watch in histogram

* better error handling
2021-03-30 14:43:53 -07:00
Bruce Martin bfb9e1edcc increase default diffexp gene count to 50 (#2130)
* increase default diffexp gene count

* try tiledb version fix
2021-03-30 13:10:09 -07:00
Madison Dunitz ae23c9e5b9 white space change to run tests (#2132)
* update tiledb reqs
2021-03-30 14:34:39 -05:00
Bruce Martin b494dd31f4 revert removal of cache control headers (#2118)
* revert removal of cache control headers

* always generate cache header for health route
2021-03-26 08:49:44 -07:00
Madison Dunitz 78c9d24ed4 Refactor czi_hosted and server into backend directory, pull common code into backend/common, refactor tests (#2102)
* move local_server -> backend/server server-> backend/czi_hosted, pull common code into backend/common update imports, tests and make commands
2021-03-26 00:27:07 -05:00
e6e358ddc8 Gene sets UI, right sidebar refactor (#2097)
* prototyping

* render histos on open gene set

* prototyping

* render histos on open gene set

* factor out add genes to own component

* remove unused import

* mock reducer

* color by geneset stub

* menus and buttons

* geneset dialogue stub

* remove heatmap mock

* componetize histogram

* reenable add genes

* re-add isuserdefined

* test data

* remove have fetched

* add isExpanded state to gene, and pass to histogram

* expand button

* toggleable

* mini

* bump number of genes to 50

* don't clear diffexp on subset

* move create category to top

* render diffexp as geneset

* geneset show mean expression

* gene set reducer

* add geneset UI reducer

* wire e2e gene set loading prototype

* fix sniffing bug

* fix typo

* add gene modals

* client/src/actions/

* add autosave

* rename data-dir cli param

* add geneset, add gene, delete set

* prototype: remove csv upload placeholder

* handle delete gene from set

* prepopulate geneset with genes from modal

* add geneset: rename action

* icons, language consistency

* chevron after

* handle empty string case on genes for create geneset

* edit geneset

* fix language on create

* copy correction

* add popper2
upgrade react popper
upgrade react popper
adding popover2 package

* truncate uses tooltip2

* gene set button text typo

* remove logging

* moving server over

* remove test imports

* don't try to destructure map, use array.from

* fix add gene map datastructure error

* Revert "fix add gene map datastructure error"

This reverts commit b0eed45952.

* name --> genesetName, genes --> geneSymbols

* add gene to geneset, temporary format

* handle empty case, clear form input

* lint -- genesets wasn't passed via props

* userinfo

* move genes string to object conversion to action

* remove tmp gene description

* emptystring default for description

* remove empty string

* remove top level package json

* remove package lock as well

* remove flag for feature toggle

* remove comments in geneset

* comment cleanup

* remove comment

* revert diffexp genes to 10

* color by gene set

* disable color by gene set

* Gene menus are now inline, remove dead prototype code

* remove todo, magic number to variable

* remove jshint in rightsidebar

Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>

* remove unused geneset validation code

* tmp format pending geneset description

* move magic number into variable

* reorganize genesetsUI reducer pending tests

* rewire edit given new action name

* add basic validation and feedback for geneset name uniqueness

* mv annoDialog

* mv label, repair paths

* Update client/src/components/brushableHistogram/header.js

Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>

* add imports for icon in histo

* update jest snapshots given blueprint/tooltip2 usage of index -1

* ensure no empty paragraph

* intent from blueprint

* remove remainder of jshint references

* do not push undo when autosave fires

* fix autosave bugs

* remove todos

* clamp to util

* scient to util

* revert clearing diffexp

* rename value to be more specific stacked bar

* clean up logging and commetns

* remove gene entry tests pending rewrite

* tab index -1

* update jest snapshot, blueprint tooltip 2

* caret margin

* snapshot update

* ensure histogram is centered

* add geneset actions to config

* comment maybeScientific

* comment clamp

* comment ui reducer

* remove prototype code

* remove error log

* remove references to bl.ocks

* componetize parseBulkGeneString

* catch case where geneset rename same name

* genesetui reducer tests

* add geneset ui to index reducer config

Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
2021-03-24 16:33:26 -04:00
Timmy Huang 5335c39184 Prod (#2123)
* thuang-126-CZIF-policy-updates (#2111)

* thuang-130-CZIF-policy-change-for-real (#2119)
2021-03-24 10:34:36 -07:00
Bruce Martin a89362c1ad update gene set name validation to match latest requirements (#2117) 2021-03-22 14:54:12 -07:00
Timmy Huang 8d932fb47d thuang-126-CZIF-policy-updates (#2111) (#2112) 2021-03-17 16:07:42 -07:00
Madison Dunitz 920d71e6b7 pin numba req (#2110)
* pin numba req

* make req more flexible
2021-03-16 11:45:27 -05:00
Bruce Martin 31e0326ded gene sets summary route (#2099)
* gene sets summary route

* lint

* clarify return type

* style
2021-03-10 16:02:05 -08:00
Severiano Badajoz 1d3d9237e7 temp disable auth testing (#2092)
* disable auth testing

* revert some deletion
2021-03-04 13:56:48 -08:00
Bruce Martin c037f4eaa6 rename "geneset" to "gene set" in CLI (#2088)
* remove dead code

* rename geneset to gene_set
2021-03-02 15:36:01 -08:00
Bruce Martin b00496198d wire up geneset reducer (#2082)
* first cut at GET /genesets route

* update existing tests to match code changes

* more GET /genesets and initial tests

* add missing test fixture

* geneset validation accepts OTA format

* genesets route: better error handling, more tests

* lint

* genesets reducer and initial load

* fix lint

* add autosave support for genesets

* remove debug logging

* fix typo

* fix another typo

* update smoke test config for genesets

* smoke test fixes

* more fiddling with smoke tests
2021-03-02 12:12:58 -08:00
maniarathi b3aadf6632 Release version 0.16.7 (#2081) 2021-02-27 22:10:53 -08:00
Bruce Martin f3a3820ffa genesets route for local server (#2079)
* first cut at GET /genesets route

* update existing tests to match code changes

* more GET /genesets and initial tests

* add missing test fixture

* geneset validation accepts OTA format

* genesets route: better error handling, more tests

* lint
2021-02-26 17:53:07 -08:00
09466a5c32 fix: server/requirements-dev.txt to reduce vulnerabilities (#2055)
The following vulnerabilities are fixed by pinning transitive dependencies:
- https://snyk.io/vuln/SNYK-PYTHON-RSA-1038401

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Madison Dunitz <madison.dunitz@chanzuckerberg.com>
2021-02-25 12:24:06 -08:00
Bruce Martin de571ce0b3 fix local_server unit-test target (#2078) 2021-02-23 16:49:43 -08:00
Marcus Kinsella fb61bd6e9c Split out the local backend (#2052)
This splits the backend into two parts: the local backend for desktop cellxgene and the AWS backend for hosted cellxgene. The local backend is in local_server while the hosted remains in server. The general idea is to copy everything from server to local_server, pull unneeded stuff out of local_server, and keep server as-is for this PR. Not touching server means all the infra and deployment code will continue working just as it did before so we can make those changes incrementally.
2021-02-18 12:58:22 -08:00
Bruce Martin 036b5f8c0f Fix indexing bug in user-specified colors (#2051)
* repaint category value when color changes

* bug fix incorrect indexing of user colors

* add test for bug 2007

* lint
2021-02-08 18:00:03 -08:00
Bruce Martin e6281baa39 improve error message for OBO parse failure (#2053) 2021-02-05 12:19:40 -08:00
Bruce Martin 3aef21f76c alpha blending fix in scatterplots (#2033)
* explicitly specify alpha blending; rename NaN flag to accurately describe function

* fix incorret dest alpha blending function
2021-02-05 09:34:37 -08:00
Björn Grüning d821f0eac9 More cellxgene Galaxy information for the extensions (#2024)
This PR adds some more text and direct links to the cellxgene Galaxy section.

Thanks!


#### Reviewers
**Functional:** 

**Readability:** 

---

## Changes
- add
- remove
- modify
2021-02-03 14:38:54 -08:00
Marcus Kinsella 3c0b1d45db Fix deprecated np.unicode type (#2035)
Until numpy version 1.20.0, numpy.unicode was an alias for str in python3. In 1.20.0, it's fully deprecated and is an int. This is bad and breaks things. This commit drops the np.unicode alias and just uses str, as is advised here:
https://numpy.org/devdocs/release/1.20.0-notes.html#deprecations
2021-02-03 09:22:27 -08:00
bmccandless 90a4ff7526 allow cellxgene datasets urls to have a trailing slash or not. (#2028)
#550
2021-01-20 15:19:13 -08:00
bmccandless d5ad823895 simple solution to the locust test problem (#2026)
In this solution, all the server requirements are installed.
This is a slightly overkill, but it avoid having to restructure
any of the server or test code to avoid unnecessary imports.

 #2019
2021-01-20 08:56:54 -08:00
bmccandless 0e48b335be update umap version (#2021)
There is an interface change in 0.5.0 which is not compatible with our
version of scanpy.
2021-01-13 13:01:46 -08:00
Ambrose J Carr e264724597 Add extension showcase to documentation (#1878) 2021-01-12 08:55:14 -05:00
bmccandless c7eb319817 P value and Log fold change not showing up for all DE results (#2016)
The HistogramFooter needs to distinguish between an undefined
value and a value of 0.  If the pvalAdj was 0, then the logFolChange
was previously not showing up.

 #1888
2021-01-08 09:44:17 -08:00
dependabot[bot] db559467a2 Bump ini from 1.3.5 to 1.3.7 in /client (#2000)
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.7.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/npm/ini/commit/c74c8af35f32b801a7e82a8309eab792a95932f6"><code>c74c8af</code></a> 1.3.7</li>
<li><a href="https://github.com/npm/ini/commit/024b8b55ac1c980c6225607b007714c54eb501ba"><code>024b8b5</code></a> update deps, add linting</li>
<li><a href="https://github.com/npm/ini/commit/032fbaf5f0b98fce70c8cc380e0d05177a9c9073"><code>032fbaf</code></a> Use Object.create(null) to avoid default object property hazards</li>
<li><a href="https://github.com/npm/ini/commit/2da90391ef70db41d10f013e3a87f9a8c5d01a72"><code>2da9039</code></a> 1.3.6</li>
<li><a href="https://github.com/npm/ini/commit/cfea636f534b5ca7550d2c28b7d1a95d936d56c6"><code>cfea636</code></a> better git push script, before publish instead of after</li>
<li><a href="https://github.com/npm/ini/commit/56d2805e07ccd94e2ba0984ac9240ff02d44b6f1"><code>56d2805</code></a> do not allow invalid hazardous string as section name</li>
<li>See full diff in <a href="https://github.com/isaacs/ini/compare/v1.3.5...v1.3.7">compare view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a href="https://www.npmjs.com/~isaacs">isaacs</a>, a new releaser for ini since your current version.</p>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ini&package-manager=npm_and_yarn&previous-version=1.3.5&new-version=1.3.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
- `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language
- `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language
- `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language
- `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language

You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/chanzuckerberg/cellxgene/network/alerts).

</details>
2021-01-04 17:14:45 -08:00
dependabot[bot] 96362b0b98 Bump urijs from 1.19.2 to 1.19.5 in /client (#2012)
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.2 to 1.19.5.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.2...v1.19.5)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-04 16:45:23 -08:00
bmccandless 7599af252d Update compatibility test (#2009)
Add comment in requirements about h5py and anndata.

  #1963
2020-12-21 14:38:07 -06:00
Trent Smith f07e174a06 Fix PR Template (#1999) 2020-12-17 16:29:23 -08:00
Marcus Kinsella 46d02b1987 Handle schema v1.1.0 (#2002)
Correctly display datasets that follow schema version 1.1.0
2020-12-14 15:20:29 -08:00
Severiano Badajoz 0b91371ea8 check if viewport has changed and render accordingly (#1996) 2020-12-03 15:14:57 -08:00
Trent Smith c428242878 pull request template (#1997) 2020-12-03 12:13:31 -08:00
Isaiah Norton 9be086b1e8 Use sparse=True for from_pandas (#1994) 2020-12-02 13:39:19 -08:00
maniarathi 9a34264f92 Pin tiledb version to exclude 0.7.2 (#1993) 2020-11-30 11:54:33 -05:00
maniarathi 66e55ba59a Replace outdated locustio package with locust package. (#1992) 2020-11-30 10:11:57 -05:00
maniarathi f700fb7757 Setup codeql scanning for security and code quality analysis (#1991) 2020-11-24 21:53:10 -05:00
maniarathi ae310097dd Fix typo (#1990) 2020-11-24 12:23:21 -05:00
maniarathi ea70a35a01 Fixing locust scale tests for cellxgene loading apis and adding a Github Actions workflow to run the tests every Sunday. (#1988) 2020-11-24 09:08:57 -08:00
maniarathi 16718f392f Sorry! Undo-ing commit to main 2020-11-24 10:22:24 -05:00
maniarathi ad6b1928e4 Upgrade anndata and h5py 2020-11-24 10:21:24 -05:00
bmccandless 2cc02a84cb Convert float annotations if possible. (#1987)
* Convert float annotations if possible.

The client converts all arrays to floats.
If a category contains integer labels, and that category is copied, it will contains floats (e.g 1.0 instead of 1).
When that category is put back to the server, it fails in the tiledb code, which does not accept floats.
The solution is to convert a float category to integer, if possible.

  #1984

* updates
2020-11-20 17:01:53 -06:00
Marcus Kinsella f77038ad58 Permit other keys in the cxguser cookie (#1982) 2020-11-18 17:58:33 -08:00
Timmy Huang 2cf55ab819 thuang-compress-annotation (#1980)
* thuang-compress-annotation

* compress test

* use zlib.decompress directly
2020-11-17 14:05:07 -08:00
bmccandless 095db02439 Remove deprecated health endpoint (#1943)
NOTE:  do not push to main until the new path has been terraformed into all the environments.

 #1846
2020-11-13 10:49:11 -08:00
dependabot[bot]andSeveriano Badajoz 85fc000418 Bump dot-prop from 4.2.0 to 4.2.1 in /client (#1954)
Bumps [dot-prop](https://github.com/sindresorhus/dot-prop) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/sindresorhus/dot-prop/releases)
- [Commits](https://github.com/sindresorhus/dot-prop/compare/v4.2.0...v4.2.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
2020-11-09 22:05:34 -08:00
Isaiah Norton 34742b9a94 Add link to dev_docs in Contributing section (#1957) 2020-11-09 21:57:00 -08:00
06b88cda20 do not update GPU buffers if data has not changed (#1967)
Co-authored-by: maniarathi <mani.arathi@gmail.com>
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
2020-11-09 10:41:39 -08:00
bmccandless 23714bc9f8 Fix bug that occurs when all categories are removed. (#1974)
Previously if the user remove all annotations, the code would still generate a tiledb uri
in the write_labels call, and add that to the database.  A tiledb array would not be written in this case.
When the read_labels was then called, it would find the entry in the database, attempt to open
the tiledb array, then fail.

The patch here will set the tiledb_uri to the empty string if all categories are removed.
When read_labels is called, it will see the empty uri and return None.
Furthermore, if the database does have a tiledb_uri that does not exist, or cannot be read,
then the code will now log a warning, and return None (instead of throwing an exception,
which results in a server error).

 #1932
2020-11-06 18:04:14 -08:00
Marcus Kinsella e892e64685 Convert HGNC ids to their symbol (#1972)
There are entries in some var indexes like HGNC:18790. We'd like to convert that to its symbol, NSG1.
2020-11-06 09:27:49 -08:00
Marcus Kinsellaandmaniarathi 39a1124c35 Fix manifest to include schema definitions (#1965)
🤦

Co-authored-by: maniarathi <mani.arathi@gmail.com>
2020-11-05 09:41:05 -08:00
Bruce Martin b5856ff9bc fix accidental state error (#1966) 2020-11-04 18:46:04 -08:00
Severiano Badajoz d87551ce5e fix version check (#1968)
Inverted the schema version check so that we check that it IS 1.0.0.

![image](https://user-images.githubusercontent.com/8716829/98184898-5c897b00-1ec0-11eb-89f7-c293dfbe8439.png)
2020-11-04 17:43:41 -08:00
Severiano Badajozandmaniarathi a5c9ae2432 Data drawer final round of prod QA fixes (#1955)
* add long title

* add organism to Dataset Metadata and create headers

* begin HTMLTable for metadata

* switch out truncating for scrolling

* add optional chaining to redux state mapping

Co-authored-by: maniarathi <mani.arathi@gmail.com>
2020-11-04 09:43:43 -08:00
Marcus Kinsella 78176f9711 Add schema subcommand (#1939)
Add the `cellxgene schema apply` and `cellxgene schema validate` subcommands.

The first takes an h5ad file and a yaml with config information and produces a new h5ad that follows the cellxgene data integration schema.

The second takes an h5ad and checks if it follows the schema version written into its metadata.

Both are currently marked as "experimental" as the primary intended users are still at CZI.
2020-11-02 08:26:37 -08:00
bmccandless b9e132a00c Updates due dependency version changes. (#1960)
* Updates due dependency version changes.

h5py recently changes and now values once returned as str are now returned as bytes.
This would have caused a much larger change, so instead the version is restricted to <3.0.0.

This caused the bulk of the testing failues.
A few other changes were needed to make a few other tests pass.

 #1959
2020-11-01 12:36:38 -08:00
Madison Dunitz 3b6c46ba86 Fix dependency issues in compatibility tests (#1951)
* update reqs

* pin scanpy

* merge in fix for race conditions
2020-10-30 10:47:12 -05:00
bmccandless 6a1e5f71be fix race condition in test_oauth (#1956) 2020-10-29 11:05:23 -07:00
Severiano BadajozandMadison Dunitz 727af83152 remove conditional rendering cases from color legend (#1952)
* Revert "Remove Continuous vars with 1 value from histogram, add to info drawer (#1927)"

This reverts commit 242546371b.

* remove conditional rendering cases

* ignore pointer events

Co-authored-by: Madison Dunitz <madison.dunitz@chanzuckerberg.com>
2020-10-28 15:35:28 -07:00
Severiano Badajoz 924b518492 Revert "Remove Continuous vars with 1 value from histogram, add to info drawer (#1927)" (#1953)
This reverts commit 242546371b.
2020-10-27 17:12:13 -07:00
Madison Dunitz 946a910ef4 Fix compatibility test (#1948)
* update anndata version and warning about version

* update compatibility tests
2020-10-26 17:23:55 -05:00
bmccandless 7e9353c5f1 Fix bug in oauth. (#1949)
* Fix bug in oauth.

The error checking was too specific, and missed a case.
Make the error checking catch all exceptions.

  #1947

* Add logging when the cookie cannot be processed
2020-10-26 09:39:06 -07:00
bmccandless c106ebc525 smnall fix to the test suite. (#1944)
I noticed a few tests failed when run individually, but not as a suite.

 #1942
2020-10-23 15:14:31 -07:00
Severiano Badajoz 2fa206f2ad Add token invalidation tests to oauth tests (#1941)
* add tests

* run black

* run black and add disclaimer that tweaked errors on server

* lint

* change to get so it will return None

* tweak existing token instead of new one

* Trigger

* token is dict

* jsonify dict before encoding

* json dump instead of jsonify

* encode into bytes object

* use correct id token

* decode byte to string
2020-10-23 14:51:48 -07:00
bmccandless f41a023418 Minor changes to eb server to use Docker (#1938)
part of #1866
2020-10-22 17:04:08 -07:00
maniarathi 9793398737 Add in missing previous crossfilter which was causing the re-embedding feature to fail. (#1936) 2020-10-21 09:16:13 -07:00
maniarathi 377e4bccaa Remove errornous checking for converting float64 to float32. In reality the slight difference by downcasting is totally fine. (#1935) 2020-10-19 10:31:36 -07:00
bmccandlessandMadison Dunitz 6a741956e1 Update readme for eb server. (#1928)
* Update readme for eb server.

Update the README with new way of handling secrets.
Update portions that were out of date.
Add a section for Authentication and a placeholder for User Annotations.

Also remove an obsolete function that processes the AWS secrets.

  #1522

Co-authored-by: Madison Dunitz <madison.dunitz@chanzuckerberg.com>
2020-10-16 14:02:05 -07:00
Severiano Badajoz c9f9549118 Adopt JS standards once userinfo data is in frontend (#1930) 2020-10-16 11:59:28 -07:00
Madison Dunitz 242546371b Remove Continuous vars with 1 value from histogram, add to info drawer (#1927)
* remove single val continous metadata from histogram, add to info drawer

* refactor to save singleContinuous values in state

* fix edge case, single continuous values reappeard in rsb when clipped
2020-10-14 12:46:24 -05:00
maniarathi 798976e4c1 Fix custom color handling (#1929) 2020-10-13 15:47:56 -07:00
maniarathi 5325495123 Speed up dataset drawer rendering (#1926) 2020-10-12 11:20:58 -07:00
Severiano Badajozandczimergebot beb46bf3df add and check system arg to state auth type in e2e test(#1924)
* add and check system arg to state auth type

* add tolower

Co-authored-by: czimergebot <35308261+czimergebot@users.noreply.github.com>
2020-10-09 12:42:02 -06:00
maniarathi 86ff48ae36 Revert "Allow columns encoded in float64 to be rendered as part of continuous value histograms. (#1905)" (#1925)
This reverts commit b048fd8d9a.
2020-10-09 10:09:32 -07:00
Timmy Huang c01a2c72b6 thuang-1840-authn-prompt (#1911) 2020-10-08 16:57:53 -07:00
Severiano Badajoz 6677d0de56 disable profile picture (#1923) 2020-10-08 12:16:02 -06:00
Severiano Badajoz c4c48b9a57 create e2e test for auth buttons (#1907)
This PR adds a few helpful additions regarding authentication.

Changes:
* e2e tests are now run on test_oauth via a passed config.yaml 
* node dev server correctly handles `/login` and `/logout` endpoints to make developing for auth easier
* Introduced auth e2e tests to check that buttons display and work
2020-10-08 10:02:40 -07:00
bmccandless b5ec43c4b1 Add a function to check the configuration for errors. (#1919)
This can be used as a sanity check before a deployment:

  chanzuckerberg/single-cell#63
2020-10-08 08:44:09 -07:00
bmccandless 6c1756f852 Enhance the AppConfig with external config sources. (#1904)
* Enhance the AppConfig with external config sources.

The external config sources are currently environment variables
and AWS secrets manager.

The config file can be augmented with a section describing how
environmen variables and secrets can update config parameters.

benefits:
 - it will enable the config to draw from more than one secret.  This is useful
   for shared secrets between cellxgene and data portal, as well as auth0 secrets.
 - it will make it very straightforward to check the config before a deployment.

 Part of #1859
2020-10-07 15:38:42 -07:00
Severiano BadajozandTimmy Huang 1c4c501c43 Auth UI tweaks (#1915)
* remove auth buttons and dataset info from info menu

* add auth buttons to menubar

* remove auth from top left

* new auth buttons

* move infomenu to lsb dir

* styling fixes

* feedback

* more feedback

Co-authored-by: Timmy Huang <thuang@chanzuckerberg.com>
2020-10-07 15:02:55 -07:00
bmccandless cf77a8da9e Add "picture" to the /userinfo endpoint. (#1914)
* Add "picture" to the /userinfo endpoint.

This may be null or a URL.
 add picture for the test authentication method
2020-10-07 12:17:23 -07:00
Madison Dunitz eb108feb37 Performance test annotations (#1908)
* make testing plan

* create annotaions sets for different num categories/dataset size

* annotation creation testing

* create scale and perf tests for annotations

* create make commands for tests

* get cell count if not set in test_datasets dict
2020-10-07 12:36:02 -05:00
Leslie b386ca3425 Move link to cellxgene data portal higher (#1909) 2020-10-06 13:16:59 -07:00
maniarathi b048fd8d9a Allow columns encoded in float64 to be rendered as part of continuous value histograms. (#1905) 2020-10-06 12:59:07 -07:00
Leslie 3718e894ed Removed legacy landing page and updated cxg readme (#1897) 2020-10-02 12:10:55 -07:00
evanbiederstedt 1f9bba6f00 readme correction (#1896) 2020-10-01 22:44:59 -04:00
Timmy Huang 8bd4cbd1e5 1807-authN-smoke-test (#1898)
This PR does the following:

1. Add `login` and `logout` helper functions in `client/__tests__/e2e/cellxgeneActions.js`
2. Add conditional AuthN integration test in `client/__tests__/e2e/e2e.test.js`. The test will only run if env variable `TEST_AUTH_INTEGRATION` is `"true"`, which is only set in `single-cell-infra`'s Github Action flow. Corresponding PR [here](https://github.com/chanzuckerberg/single-cell-infra/pull/198)
2020-10-01 12:29:59 -07:00
dependabot[bot]andSeveriano Badajoz e6c996ca93 Bump bl from 4.0.2 to 4.0.3 in /client (#1810)
Bumps [bl](https://github.com/rvagg/bl) from 4.0.2 to 4.0.3.
- [Release notes](https://github.com/rvagg/bl/releases)
- [Commits](https://github.com/rvagg/bl/compare/v4.0.2...v4.0.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
2020-09-30 15:24:51 -07:00
Timmy Huang 99152dd5aa add-menu-test-id (#1895) 2020-09-30 12:53:51 -07:00
Colin Megill 04a3c3c6b6 Partial fix for 1830 (#1863)
* Remove door icon from log in button
* Move log in and info buttons from the top bar to in line with the cellxgene icon and dataset name
* Hover over on login button should say "Log in to cellxgene"
* Show email

closes #1830
2020-09-30 11:45:10 -07:00
Madison Dunitz 998fa4762d run black formatter on repo (#1891)
* add black to lint make cmd

* add black dependency to installation to push test pipeline
2020-09-30 11:16:13 -05:00
Madison Dunitzandbmccandless 2ffe5ffcae remove AppFeature and all references to it in the code/tests (#1893)
* remove AppFeature and all references to it in the code/tests

Co-authored-by: bmccandless <bmccandless@chanzuckerberg.com>
2020-09-29 18:31:59 -05:00
maniarathi 4aabb8e092 Make sure there are more than 1 values in a category before rendering it (#1871) 2020-09-29 15:32:21 -07:00
Severiano Badajozandczimergebot 7bee09cd16 Add blueprint eslint plugin (#1892)
* add bp3 eslint plugin

* first eslint runthrough + manual changes

* small fixes

* update snapshots

* update h1 to h4

Co-authored-by: czimergebot <35308261+czimergebot@users.noreply.github.com>
2020-09-29 15:00:56 -07:00
Madison Dunitz af3c6e1d8e config refactor (#1854)
* split out config

* add tests for base and app config, refactor client config out of app config

* refactor default config retrieval

* create config test class and helper functions

* move default_config into server to fix import issue
2020-09-29 16:42:46 -05:00
bmccandless 1145f61c78 auth: logging out should keep the user on the same page (#1877)
previous behavior is that logout would redirect to the index page.
2020-09-29 13:42:24 -07:00
maniarathi 863ca8be03 Fix license years and add CZI (#1882) 2020-09-28 16:44:56 -07:00
Severiano Badajoz 21dfdb91a9 skip user annos when building dataset metadata (#1881) 2020-09-28 13:17:14 -07:00
Severiano Badajoz 374bb11279 Handle case where new drag starts while existing lasso is not finished (#1864)
* handle case where new drag starts while existing lasso is not finished

* flip variable
2020-09-28 10:34:47 -07:00
bmccandless 3e2d7174fd Add user email to the userinfo response (#1862)
We are planning to display the user's email address in the front end.

 #1830
2020-09-23 11:46:56 -07:00
bmccandless a817a94eec Bug reading the config file. (#1857)
The config file had a bug where it expected both a "server" and "dataset" section.
If one didn't exist, then it would raise an exception.
It should use the default server config or the defaul dataset config in those cases.
Added a test case that would have caught this.
2020-09-18 19:05:14 -07:00
Severiano Badajoz 210042814f Info Drawer format adjustments (#1853)
This PR tweaks the look and feel of the info drawer in response to QA from @signechambers1
2020-09-18 13:16:28 -07:00
bmccandless 14fbe0aa77 Fix the /health endpoint (#1847)
* Fix the /health endpoint

 #1846

Keep both the old and new locations until the deployments are upgraded.
2020-09-17 17:14:08 -07:00
bmccandless 25c272ae8e minor fix to auth redirect (#1845)
The previous version added and extra "/" to the url after login:
e.g:  https://cellxgene.dev.single-cell.czi.technology/d/pbmc3k.cxg//
2020-09-16 17:37:52 -07:00
maniarathi a63bf9d5a3 Change psycopg to be binary (#1842) 2020-09-16 14:46:59 -07:00
Severiano Badajoz 3e9cb0265e Fix InfoFormat parameter checking (#1831)
Went through and ensured that undefined/null values were caught and handled correctly in render functions.  Also documented some of the more complicated functions.

---
Closes #1825
2020-09-15 12:03:59 -07:00
Madison Dunitz 4f339e89b1 dont cache schema (#1836) 2020-09-15 11:20:00 -05:00
maniarathi 9fac6849a3 Fix import of anndata from master so that there aren't issues with scanpy version checking. (#1834) 2020-09-14 17:19:24 -07:00
bmccandless 342a9d774c app config bug fix: (#1833)
* app config bug fix:

When reading a config file that included per_dataset_config,
the dataroot specializations were applied, but not the default config.
This PR fixes that and also includes a test for this case.
2020-09-14 13:15:47 -07:00
bmccandless 6a7ae8bc8e Fixes from frontend/backend url separation (#1829)
* Fixes from frontend/backend url separation

This fixes the CORS and CSP headers.

Also, in thie commit, I removed the cors_supports_credentials config parameter,
which was recently introduced.
Instead, the logic determines the need to use CORS headers if the
web_page_url is set.

 #1778
2020-09-12 10:56:31 -07:00
maniarathi 4b240920e2 Pass in the previous crossfilter when creating a new annomatrix for a switched embedding in order to retain the previous selection of cells. (#1832)
* Pass in the previous crossfilter when creating a new annomatrix for a switched embedding in order to retain the previous selection of cells.

* Address Bruce's PR comment
2020-09-12 10:32:00 -07:00
bmccandless a7a4580944 separate backend base url from frontend (#1819)
* separate backend base url from frontend

This is needed for auth, and to support a different location for the backend api server,
than the frontend.

 part of chanzuckerberg/cellxgene#1778

new server config parameters:   app__api_base_url,   app__web_base_url

Also changed api_base_url in the oauth config section to "oauth_api_base_url" to
be less confusing with the app's api_base_url

Other minor changes:

changed how the jwt decode options are handled.
Previously they needed to be set in a test case, and there was some extra logic to handle that.
Now they are handled through comfig parameters, which makes it more general.

Also, add a feature to set the CORS support credentials, which seems
to be necessary for the backend/frontend separation, at least when run
locally.  This part is sort of experimental, and may be removed or changed later.
2020-09-11 09:50:16 -07:00
bmccandless 3f20f4a1f4 Change modify upgrade message to print to stderr instead of stdout (#1827)
When generating a config file, you can do this:
 > cellxgene launch --dump-default-config > myconfig.yaml

And then modify the myconfig.yaml.

However, if an upgrade is available then you would get extra lines in the yaml
file, which are not yaml code:

  There's a new version of cellxgene available (0.16.4)!
  To upgrade, run the following: pip install --upgrade cellxgene

To solve this problem, the upgrade messages are sent to stderr instead,
so they will appear on the screen and not in the config file.

Alternatives:
  One workaround is "cellxgene --no-upgrade-check launch --dump-default-config > myconfig.yaml"

But that's a bit verbose and not user friendly.

The way we've setup the upgrade check to be separate and before the launch sub command,
makes other code changes more involved.

 #1826
2020-09-11 09:24:25 -07:00
Severiano Badajoz 5583e91392 Pull config values into dataset overview drawer (#1814)
This PR adds multiple data to the dataset overview drawer provided by the config endpoint and formats them accordingly.  The appearance of this new data is contingent on `dataPortalProps.corpora_schema_version === "1.0.0"`

For QA launch cellxgene with a remixed dataset and click on the button in the upper left-hand corner or the updated button in the info menu.


![image](https://user-images.githubusercontent.com/8716829/92670435-de966280-f2c8-11ea-87f1-8591c959a586.png)


~~Review opening is blocked by merge of #1805~~

---

Closes #1319
2020-09-10 16:41:05 -07:00
Severiano Badajoz 89b68723cc Create dataset info drawer (#1805)
* create infoDrawer

* create read/writes to redux store

* reimplement reducer that vanished

* remove aboutURL stuff from title

* add formatting and style

* s/length/size and make metadata items list items

* remove comment

* remove empty singletons

* refactor into async react component

* Clean up skeleton

* swap out for loop for map

* add comment

* replace placeholder

* switch ternary for `&&`

* event handling fixes and PR feedback

* add button and move click handler to button

* ditch empty categories

* move drawer button handling to redux

* remove categorical move note

* PR feedback from colin

* update snapshot

* remove hover state
2020-09-09 17:55:43 -07:00
Ambrose J Carr 5781879da5 remove core team section (#1798) 2020-09-02 20:22:31 -04:00
bmccandless 0a27b2923a Add error message and exit if reembeddings is enabled and scanpy is n… (#1812)
* Add error message and exit if reembeddings is enabled and scanpy is not installed

  fixes #1811
2020-09-02 15:35:50 -07:00
Severiano Badajoz 437fd5feda Correctly check if mini histograms shouldn't be rendered (#1809)
* ensure that function returns a boolean value

* change function used to check if mini histogram should not render
2020-09-01 16:45:50 -07:00
bmccandless 54b42607ae Update the location of deployment assets for the eb server (#1806)
put deploy scripts in /static/cellxgene/deploy instead of /static/deploy

  fixed chanzuckerberg/corpora-data-portal#558
2020-08-31 18:26:35 -07:00
maniarathi ed865e9a57 Update the release process for community release to include release candidate versioning (#1802) 2020-08-31 16:16:21 -07:00
bmccandless f8cdb12892 Fix frontend mishandling of null userinfo (#1795)
* Fix frontend mishandling of null userinfo

If the authentication is disabled, the userinfo endpoint returns null.
This case needs to be handled.

 #1780

* Small fix for handling refesh tokens in auth
2020-08-26 13:01:50 -07:00
bmccandless ab1b9368a0 fix pca call in reembeddings (#1793)
This had the wrong dim passed into n_comps,
and so failed when the number of genes was less than 50.
2020-08-25 17:21:39 -07:00
Severiano Badajoz 0a10b3ec2a sort object keys to our specification before generating user colormap (#1792) 2020-08-25 12:25:55 -07:00
Prete eb05d1cb5c Update Dockerfile (#1775)
* Update Dockerfile

- Update Ubuntu Focal (20.04)
- Add `DEBIAN_FRONTEND=noninteractive` to prevent dialog boxes during installation

* Changed 'pip3 install --upgrade pip' to 'python3 -m pip install --upgrade pip' as described here https://github.com/pypa/pip/issues/5599
2020-08-25 09:37:38 -07:00
Timmy Huang 9a40b28172 thuang-fix-static-asset-font (#1791)
This seems to fix the font URL path, at least locally for both `:3000` and `:5005`

<img width="1296" alt="Screen Shot 2020-08-24 at 4 01 07 PM" src="https://user-images.githubusercontent.com/6309723/91106044-8a338780-e626-11ea-885f-e5c268f3ecf0.png">

<img width="1377" alt="Screen Shot 2020-08-24 at 4 01 28 PM" src="https://user-images.githubusercontent.com/6309723/91106047-8dc70e80-e626-11ea-95b0-62e47cc6303f.png">
2020-08-24 17:05:46 -07:00
65ea1b673f Dunitz 1685 hosted annotations (#1789)
* save tiledb array to s3, dont cache user annotations

* Add option to disable annotation filename prompt (#1787)

Co-authored-by: Madison Dunitz <dunitzm@gmail.com>

* set tiledb default context in cxg_adaptor

Co-authored-by: maniarathi <arathi.mani@chanzuckerberg.com>
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
2020-08-24 18:26:08 -05:00
maniarathi 5dfe0043c3 Serves static assets from each dataset root URL and switch the publicPath to be a relative path. (#1786) 2020-08-22 10:04:40 -07:00
maniarathi bc150a8469 Fixing bugs in cxg conversion tool (#1782) 2020-08-22 09:53:59 -07:00
maniarathi a5c9ffa880 When reading annotations from tiledb, check if the values are byte literals and if so, decode them. Also pin s3f3 to 0.4.2. (#1788) 2020-08-22 09:42:11 -07:00
Snyk bot fae9ac9382 Upgrade lodash from 4.17.15 to 4.17.20 (#1759)
<h3>Snyk has created this PR to fix one or more vulnerable packages in the `npm` dependencies of this project.</h3>

![merge advice](https://app.snyk.io/badges/merge-advice/?package_manager=npm&package_name=lodash&from_version=4.17.15&to_version=4.17.20&pr_id=31842747-752f-42e7-b1f2-8fa3f51d0e21&visibility=true&has_feature_flag=false)



#### Changes included in this PR

- Changes to the following files to upgrade the vulnerable dependencies to a fixed version:
    - client/package.json
    - client/package-lock.json



#### Vulnerabilities that will be fixed
##### With an upgrade:
Severity                   | Priority Score (*)                   | Issue                   | Breaking Change                   | Exploit Maturity
:-------------------------:|-------------------------|:-------------------------|:-------------------------|:-------------------------
![high severity](https://res.cloudinary.com/snyk/image/upload/w_20,h_20/v1561977819/icon/h.png "high severity")  |  **776/1000**  <br/> **Why?** Recently disclosed, Has a fix available, CVSS 9.8  | Prototype Pollution <br/>[SNYK-JS-LODASH-590103](https://snyk.io/vuln/SNYK-JS-LODASH-590103) |  No  | No Known Exploit 

(*) Note that the real score may have changed since the PR was raised.











Check the changes in this PR to ensure they won't cause issues with your project.



------------



**Note:** *You are seeing this because you or someone else with access to this repository has authorized Snyk to open fix PRs.*

For more information:  <img src="https://api.segment.io/v1/pixel/track?data=eyJ3cml0ZUtleSI6InJyWmxZcEdHY2RyTHZsb0lYd0dUcVg4WkFRTnNCOUEwIiwiYW5vbnltb3VzSWQiOiIzMTg0Mjc0Ny03NTJmLTQyZTctYjFmMi04ZmEzZjUxZDBlMjEiLCJldmVudCI6IlBSIHZpZXdlZCIsInByb3BlcnRpZXMiOnsicHJJZCI6IjMxODQyNzQ3LTc1MmYtNDJlNy1iMWYyLThmYTNmNTFkMGUyMSJ9fQ==" width="0" height="0"/>
🧐 [View latest project report](https://app.snyk.io/org/cellxgene/project/9195ddb9-6feb-469e-ad47-f5dc24c811fe)

🛠 [Adjust project settings](https://app.snyk.io/org/cellxgene/project/9195ddb9-6feb-469e-ad47-f5dc24c811fe/settings)

📚 [Read more about Snyk's upgrade and patch logic](https://support.snyk.io/hc/en-us/articles/360003891078-Snyk-patches-to-fix-vulnerabilities)

[//]: # (snyk:metadata:{"prId":"31842747-752f-42e7-b1f2-8fa3f51d0e21","dependencies":[{"name":"lodash","from":"4.17.15","to":"4.17.20"}],"packageManager":"npm","projectPublicId":"9195ddb9-6feb-469e-ad47-f5dc24c811fe","projectUrl":"https://app.snyk.io/org/cellxgene/project/9195ddb9-6feb-469e-ad47-f5dc24c811fe?utm_source=github&utm_medium=fix-pr","type":"auto","patch":[],"vulns":["SNYK-JS-LODASH-590103"],"upgrade":["SNYK-JS-LODASH-590103"],"isBreakingChange":false,"env":"prod","prType":"fix","templateVariants":["updated-fix-title","priorityScore","merge-advice-badge-shown"],"priorityScoreList":[776]})
2020-08-20 11:03:30 -07:00
bmccandless 924aaf9aef Allow user_annotations in the eb app (#1781) 2020-08-18 17:13:57 -07:00
bmccandless 950be4426d Handle the refresh token in oauth authentication (#1766)
* Handle the refresh token in oauth authentication

If the token has expired, then it can be refreshed to get a new token.
This is automatically handled by the server without the client being aware.

Also in the PR:
  - refactor the auth_oauth.py file to more simply handle the save/restore of the token,
    and the refresh token
  - added an end2end test for oauth, which also tests refresh.

* adding python-jose and Authlib to requirements-dev.txt

They are needed in the auth_oauth test
2020-08-18 14:41:15 -07:00
maniarathi 053f39d49e Cleaning up one script that makes use of the non-existent cxgtool. (#1765) 2020-08-17 18:40:26 -07:00
maniarathi 994c20c094 Move cxgtool into CLI and modularize conversion functions (#1701) 2020-08-17 17:28:29 -07:00
Severiano Badajoz 1acb8e4a6f Remove support for non-chromium Edge (#1761)
* bump browserlist Edge to 79+

* bump edge version on unsupported browser page
2020-08-17 16:51:58 -07:00
bmccandlessandColin Megill 298924fef5 Separate userinfo from the config endpoint (#1728)
* Separate userinfo from the config endpoint

previously information about if the user was logged in and their username
was part of the config endpoint.
However, the config endpoint was previously static, and has a cache control.
Rather than not caching the config, a new endpoint called "userinfo"
is created to handle that information.

The config endpoint still has the non-changing part of the authentication:

  config:
    authentication:
        requires_client_login:  True/False
        login: <uri to login endoint if requires_client_login is True>
        logout: <uri to logout endoint if requires_client_login is True>

The userinfo endpoint returns this information:

  userinfo:
    is_authenticated:  True/False
    username: <string if is_authenticated>

if authentication is not enabled then the config does not have an authentication key,
and userinfo returns None.

Also in the PR are a few minor code improvements and bug fixes

Co-authored-by: Colin Megill <colinmegill@gmail.com>
2020-08-17 13:41:03 -07:00
Colin Megill 4ad9f5875a xx, yy (#1754) 2020-08-17 11:55:49 -04:00
maniarathi 508889f74b Refactoring cxg utility classes in preparation for CXG conversion tooling (#1739) 2020-08-14 16:51:13 -07:00
Madison Dunitz b034055c35 update to get_secrets_key (#1755)
* raise exception when get_secrets fails, get db_uri and set as a default_dataset_config var

* log as info not an error
2020-08-14 18:17:21 -05:00
maniarathi 263e893b30 Revert "Patching (#1744)" (#1748)
This reverts commit 6848f7a8b2.
2020-08-14 11:22:39 -07:00
Madison Dunitz 6a82030558 remove db_uri secret (#1751)
* remove db_uri secret

* add test to catch bug in future
2020-08-14 12:38:46 -05:00
Severiano Badajoz 018f653ec6 Sunset Heroku support (#1740)
* remove experimental heroku

* add aiohttp for dataset loading via url

* Add heroku deprecation section to docs

* remove Heroku related files from root
2020-08-14 10:22:29 -07:00
bmccandless 905308e09f Move psycopg2==2.7.7 from requirements.txt to requirements-dev.txt (#1747) 2020-08-13 21:20:13 -07:00
bmccandless 3c04529523 Fix error message when datapath and dataroot are not provided (#1746)
* Fix error message when datapath and dataroot are not provided

Previously:
$ cellxgene launch
cellxgene] Starting the CLI...
AttributeError: 'NoneType' object has no attribute 'startswith'

With this fix:
$ cellxgene launch
[cellxgene] Starting the CLI...
Error: missing datapath

* lint
2020-08-13 21:10:02 -07:00
Madison Dunitz 2689d8d2c0 Create hosted user annotations [1685] (#1726)
* add function to retrieve latest annotation from db, db updates

* read and write tiledb arrays

* adding tests
2020-08-13 19:07:17 -05:00
Severiano Badajoz 1c4bb84f35 Properly generate hash and provide how-to (#1745)
* properly generate hash and provide how-to

* Add link to this PR
2020-08-13 16:50:54 -07:00
maniarathi 6848f7a8b2 Patching (#1744) 2020-08-13 14:36:42 -07:00
Timmy Huang dda530a67c add-GHActions-timeout-for-smoke-tests (#1743)
We need to explicitly set timeout for smoke tests, since GH Action's default is 360 mins (6 hours 😱 )

https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes

Thank you!
2020-08-13 13:18:01 -07:00
Severiano Badajoz a23aaa131d regenerate hash and fix url (#1742)
The script hash had a typo in it and was incorrectly generated.  The URL in the `img-src` directive also did not need to be encased in single-quotes.

Reviewers please double-check my hash generation against the inline-script here: https://github.com/chanzuckerberg/cellxgene/blob/main/client/configuration/webpack/obsoleteHTMLTemplate.html
2020-08-13 11:17:13 -07:00
Severiano Badajoz 53a268fb71 Surround script hash in single quotes (#1735)
* add disclaimer about changing the script

* add hash for obsolete browser script

* add explicit domain img-src

* add single quotes

* add quotes to `data:`

* change order and remove single quotes

* lint, remove extra slash, and make hash an array

* add links to S3

* surround script has in single quotes
2020-08-12 11:31:12 -07:00
Severiano Badajoz 518768e8f8 change asset src's to S3 links (#1734)
* add disclaimer about changing the script

* add hash for obsolete browser script

* add explicit domain img-src

* add single quotes

* add quotes to `data:`

* change order and remove single quotes

* lint, remove extra slash, and make hash an array

* add links to S3
2020-08-12 10:38:49 -07:00
maniarathi 44c738e2f4 Bumping version of cellxgene for desktop release (#1733) 2020-08-12 09:58:52 -07:00
Severiano Badajoz f221856ae1 add CSP sources for obsolete browser prompt (#1731)
Adds script hash and explicit domain to `img-src` directive
2020-08-11 16:50:46 -07:00
Madison Dunitz 80f6137528 retrieve latest annotation from db (#1723)
* add function to retrieve latest annotation from db, db updates

* dont create directory in s3
2020-08-11 15:48:12 -05:00
maniarathi 49b89ba7af Swap using href to using origin+pathname to avoid errors with including any query parameters from being passed in. (#1722) 2020-08-07 10:02:22 -07:00
Severiano Badajoz 018daacdbb create truncation edge case snapshot tests (#1695) 2020-08-07 09:54:01 -07:00
bmccandless 4291f6005d fix logout for auth0 (#1719)
* fix logout for auth0

the redirect from logout needs to be registered, and therefore cannot
be an arbitrary path in the server.  When the user logs out, they are
redirected to the index page

* no need to provide dataset to logout url
2020-08-06 11:52:19 -07:00
Colin Megill c913935d90 Login / Logout button (#1718)
* first pass auth button

* only pop anno dialogue if authenticated

* add config to ignore

* remove config
2020-08-06 14:39:58 -04:00
Severiano Badajoz 8d96477fae Remove hash source from CSP style-src directive (#1717)
* remove style csp hash generation + lint

* remove references to style_hashes
2020-08-05 16:43:35 -07:00
Severiano Badajoz b18f96da77 check for bins change for canvas draw (#1693)
* check for bins change for canvas draw

* PR feedback
2020-08-05 15:20:03 -07:00
bmccandless b5e5ee0168 Update hosted app to get the oauth client secret from the secret manager (#1713)
* Update the hosted app to get the oauth client secret from the secret manager

* fix to eb app, and set no cache on oauth endpoints
2020-08-05 12:00:35 -07:00
maniarathi 0d94c9e092 DRY-ing flatbuffer code (#1716) 2020-08-05 11:56:34 -07:00
maniarathi cdae4f9f10 Reorganize the server testing directory (#1705) 2020-08-05 08:31:02 -07:00
Marcus Kinsella 550847f763 Add dataset 29 (#1712) 2020-08-04 16:03:44 -07:00
Marcus Kinsella 6bda27f554 Add datasets 45-47 (#1706) 2020-08-04 11:07:08 -07:00
Madison Dunitz f632a8db91 Dunitz/db setup (#1619)
* initial database setup
2020-08-03 17:54:06 -05:00
8bbc183647 Explicit Browser Support (#1682)
* add FastestSmallestTextEncoderDecoder polyfill

* remove nomodule from script import

* add browserslist

* add obsolete-webpack-plugin

* switch out modern-browser for preset-env

* add prompt on non target browser

* propagate prod changes to dev

* add core-js-3 and TextEncoder TextDecoder (#1671)

* add script to remove react, style html

* propagate changes to prod

* add script-ext-html-webpack-plugin for async

* more styling

* add eslint-plugin-compat

* extend compat plugin

* add existing polyfills

* add github fetch polyfill

* add AbortController polyfill

* change promptOnNonTargetBrowser to false

* propagate

* add browser links

* prettier

* add browser support to readme

* move polyfills to webpack

* remove CDN encoder polyfill

* Add no Explorer support

* fix incorrect package name

* propagate changes

* Update README.md

Co-authored-by: Ambrose J Carr <ambrosejcarr@users.noreply.github.com>

* add new deps

* create shared config

* swap out html-loader for filestream

* sanitize template

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>
Co-authored-by: Ambrose J Carr <ambrosejcarr@users.noreply.github.com>
2020-08-03 12:50:09 -07:00
bmccandless ce13a9c7ca oauth support, add the token in a configuration specified cookie (#1702)
* oauth support, add the token in a configuration specified cookie

Previously, the id token was stored in the session token.
Now, it can be placed in a different cookie with different properties.
2020-08-03 10:45:21 -07:00
bmccandless 2afa48cf11 add oauth authentication (#1681)
* add oauth authentication

Add support for OAuth2.

Change the interface to AuthTypeBase
  - better handling of config parameters
  - add a complete_setup function for additional setup steps

Added a function wrapper to enforce authentication for the
routes that require authenticaiton.

* change fsspec requirement

fsspec 0.8.0 breaks our tests
it imports a module that is does not require.
2020-07-31 18:16:57 -07:00
dependabot[bot] bb2326525e Bump elliptic from 6.5.2 to 6.5.3 in /client (#1697)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-31 13:54:56 -04:00
Bruce Martin f2fbeff511 add support for corpora default_embedding field (#1696)
* fix mispelling

* re-implement re-embedding

* always load base embedding to fetch counts

* format

* lint

* fix tests

* lint

* fix accept handling

* test log

* more debug

* more

* more

* more

* more

* remove logging

* logging

* jsonify

* remove debugging logs

* lint

* clean up errors a bit

* fix issue found in PR review

* add support for corpora default_embedding

* fix botched merge

* PR review

* PR review
2020-07-31 07:36:48 -07:00
Severiano Badajoz 055511fe60 fix colorby popup settings (#1694) 2020-07-30 15:33:48 -07:00
Madison Dunitz d748b9f691 use czi-sci-single-cell-eng github user/access token (#1698) 2020-07-30 15:54:57 -05:00
Bruce Martin 75cb513dd9 re-implement re-embeddings (#1679)
* fix mispelling

* re-implement re-embedding

* always load base embedding to fetch counts

* format

* lint

* fix tests

* lint

* fix accept handling

* test log

* more debug

* more

* more

* more

* more

* remove logging

* logging

* jsonify

* remove debugging logs

* lint

* clean up errors a bit

* fix issue found in PR review

* PR review changes
2020-07-30 12:31:36 -07:00
bmccandless bd147abb3f Fix eb logging. (#1692)
It now logs the requests to the file

Fixes #1611
2020-07-29 16:03:47 -07:00
bmccandless 5633d7c761 Fix server exception classes (#1683)
str(e) and e.message will both show the error message.
refactored the error.py file to simplify our exception class definitions
2020-07-29 13:05:59 -07:00
maniarathi af3a76c354 Adding relative links support to jekyll (#1680) 2020-07-29 12:38:46 -07:00
Bruce Martin 59f989d26f initial support for corpora schema conventions (#1676)
* initial support for corpora schema conventions

* remove debugging print

* add corpora util module

* tests

* lint

* PR review edits

* PR changes

* more PR changes

* more PR chnages

* PR fixes

* formatting

* PR updates

* lint

* PR review
2020-07-28 17:32:27 -07:00
bmccandless 5285556415 Add basic authentication in the server (#1670)
* Add basic authentication in the server

A pattern for creating authentication methods is introduced, with three
authentication types defined:
  none - no authentication
  session - like the current session based auth used for user annotations
  test - used to test the login/logout process end to end

The config endpoint now returns informations about the authentication, like if
the user is authenticated and their username.  The redirect uri's for login and
logout are also returned if the authentication type requires login

This is the first a several PRs for authentication.

*. Update server tests to avoid hardcoded ports

test_api and test_nan_rest now use a common function for starting a test server,
than will initially choose a random port.
2020-07-28 13:28:30 -07:00
Bruce Martin bbef27b8c9 minor prose change on embedding chooser (#1678) 2020-07-27 19:34:04 -07:00
maniarathi 38ce1f90fb Update issue templates 2020-07-27 12:57:59 -07:00
maniarathi 98c5cae9f4 Update issue templates 2020-07-27 12:55:44 -07:00
Bruce Martin 14cd1b9f0b work around blueprint restriction (#1677) 2020-07-27 12:52:22 -07:00
Bruce Martin 0a626537d5 subset embedding UI improvement (#1667)
* add user flag feature to annomatrix

* add implicit subsetting for partial embeddings

* lint

* add embedding cell counts to embedding choice menu

* layout
2020-07-27 09:58:14 -07:00
maniarathi c25dd33d28 Removing duplicate index.html and regenerating files. (#1672) 2020-07-27 09:57:48 -07:00
dependabot[bot] ad1bda07e9 Bump codecov from 3.7.0 to 3.7.1 in /client (#1654)
Bumps [codecov](https://github.com/codecov/codecov-node) from 3.7.0 to 3.7.1.
- [Release notes](https://github.com/codecov/codecov-node/releases)
- [Commits](https://github.com/codecov/codecov-node/compare/v3.7.0...v3.7.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-27 11:53:57 -04:00
Ambrose J Carr 27f75641cc update index links to include 'post' directory (#1666)
* update index links to include 'post' directory

* rebuild github pages site

* document how to develop documentation

* build, not serve
2020-07-23 21:49:57 -07:00
bmccandless 0344cfacce Fix the app config for reembedding and add a test (#1664)
* Fix the app config for reembedding and add a test

* Add scanpy to requirements-dev
2020-07-23 12:27:53 -07:00
Bruce Martin 83d572cde2 force categorical treatment of user annotations (#1663) 2020-07-22 17:02:19 -07:00
Colin Megillandbkmartinjr 03bad04436 Embedding button to lower left, cell selection (#1658)
* embedding

* menu bottom left

* button

* change gutters to support lower toolbar

* fix scatterplot layout

* fix tests to match new layout

* fix smoke tests to match new layout

* better sentence, dataset.nObs to top

* scatterplot position

Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
2020-07-22 18:48:21 -04:00
Bruce Martin a44da11f3f correctly handle non-string categoricals (#1660) 2020-07-22 12:14:31 -07:00
Bruce Martin 009fa2ff02 clear selection state upon subset (#1655) 2020-07-21 12:51:39 -07:00
Bruce Martin 2715157793 categorical UI improvements (#1649)
* return undefined for unknown fields

* memoize fetch response

* handle rename of user annotation

* performance and cleanup

* fix snapshots

* fix comment

* lint
2020-07-20 08:52:41 -07:00
Bruce Martin 410566f5e1 fix label selection on trunctated category lists (#1646) 2020-07-17 17:16:15 -07:00
Bruce Martin b6412b5b8a ensure there is always an unassigned label in user created category (#1644) 2020-07-17 13:22:26 -07:00
Bruce Martin 7b99d1a1b8 update JS deps (#1642) 2020-07-17 12:38:54 -07:00
bmccandless 2cb3ae7046 Fix error handling in plugin module (#1645) 2020-07-17 08:14:10 -07:00
Bruce Martin 18d0fd78cf fix view stack bug (#1640) 2020-07-16 16:14:52 -07:00
bmccandless f223d7504e Small fix for handling display versions (#1625)
* Small fix for handling display versions

Making a distinction between __version__ and the version we display in the info panel (displayr_version).
The hosted cellxgene can overwrite the display_version using a plugin.

Improve version handling in the customized assets
2020-07-16 15:11:37 -07:00
Severiano Badajoz ed23d5dd9d fix truncation edge cases (#1637) 2020-07-16 14:00:57 -07:00
Bruce Martin 2f700b377f permit NaN in embedding coordinates (#1631)
* permit NaN in embedding coordinates

* lint
2020-07-16 11:43:56 -07:00
bmccandless 3c6d90a4db Better error checking on bad params (#1626) 2020-07-16 11:08:27 -07:00
Bruce Martin c592ff35f6 improve graph loading UI (#1636) 2020-07-16 11:07:06 -07:00
Bruce Martin 2265bfc921 render perf refinements (#1632)
* render performance improvements

* improve render perf

* remove logging

* lint

* improve memoziation
2020-07-16 09:53:46 -07:00
Bruce Martin 17c1145ee4 protect against reference detach (#1634) 2020-07-16 09:53:15 -07:00
Bruce Martin 68cc0ae16c simply state management (#1628)
* simply viewport size state management

* lint
2020-07-15 10:31:42 -07:00
Bruce Martin 1269e188be Redux refactor (#1571)
* refactor categorical controls state

* lint

* fix race condition in tests

* fix typo

* add missing update on subset

* remove obsolete code

* update jest and puppeteer major version; update all minors

* update when label changes

* remove lint from tests; increase timeouts in e2e tests

* initial refactoring to new async annomatrix

* refine error handling

* fix bad merge

* add continuous legend

* lint

* fix memoization in color table creators

* partial implementation of user defined annotations

* add new annotations action creator file

* first pass at user annotations

* additional user annotation bug fixes

* user annotation auto-save

* unit test cleanup

* lint

* refactor into multiple files

* cleanup

* add column GC

* fix several bugs in user annotations

* remove debug code

* no anonymous functions

* undo redo cleanup

* file cleanup

* scatterplot

* performance

* cleanup

* remove old code

* render in parallel with load

* fix race condition

* simply graph rendering

* render throttle DRY

* fix category label order

* fix typo in e2e test setup

* re-fix the e2e test setup

* be more tolerant of races

* anno matrix unit tests

* temp disable reembedding

* pilot port continuous histo to react-async

* name change

* lint

* fix repaint bug

* typo fix

* update snap to match new ids

* world/universe name cleanup

* move annoMatrix to src dir

* use private underscore naming convention

* fix corner case in all selected

* name cleanup

* add layout control

* init edge case

* lint

* port scatterplot

* fix label indexing bug and improve tests

* port category to react-async

* fix user annotation labelling while subset

* select all of prev layout on layout switch

* fix race with crossfilter update

* prettier lint

* fix misleading comment

* fix url composition in loader

* first pass at crossfilter tests

* lint

* lint

* fix typo

* improved error handling for network errors

* fix memoization bug

* add memo

* refactor for performnce

* add missing single-value handling in select exact parser

* small bugs discovered by tests

* lint

* additional crossfilter unit tests

* remove extraneous comment

* add support for automatic category determination

* lint

* fix render bug in category

* take advantage of schema categories guarantee

* lint

* do not clear history when resetting

* enhanced annomatrix gc

* lint

* finish renaming to follow conventions; fix clone race bug

* lint

* add priority based loading to improve initial data load UX

* crossfilter cache perf

* perf tuning

* remove timers

* documentation

* PR review changes

* PR review changes

* more PR review edits

* improve clarity of comment

* more PR review fixes

* port centroidLabels to use react-async

* remove dead code

* pr review updates

* oops, remove logging
2020-07-14 13:53:33 -07:00
bmccandless f69d141336 refactor config to support different config options for datasets in different dataroots. (#1596)
This will give us the ability to specify different config options for
different dataroots.

the key of the dataroot dictionary is no longer the same as the dataroot_url.
Previously key==dataroot_url, and now those are separated.

Added an "is_multi_dataset" function to simplify logic where it branched on single vs multi.

Simplified the rest.py interface by no longer passing in the user annotations object, since
that can be retrieved from the dataset.
2020-07-10 16:21:40 -07:00
Bruce Martin 13246cb6d1 add more datasets (#1618)
* add more datasets

* add more about links
2020-07-10 14:55:55 -07:00
Bruce Martin dcbee43b88 add kampmann datasets (#1617)
* add kampmann datasets

* build for production
2020-07-10 05:12:21 -07:00
Severiano Badajoz 45cac75665 Explicitly set Heroku button branch as main (#1609)
Heroku was implicitly pulling the app.json from our deprecated `master` branch.
2020-07-07 16:49:55 -07:00
Severiano Badajoz 75be3a83f9 fix buildpack syntax (#1608)
There was a syntax error in our app.json that was leading to the inability to use the "deploy-to-heroku" button.
2020-07-07 16:27:08 -07:00
bmccandless f3d5081d38 exclude a tiledb version that has a known bug (#1604)
exclude a tiledb version that has a known bug
2020-07-07 08:39:50 -07:00
Matt Weiden e5670d1732 Do not cache index.html (#1603)
* Fix typo

* Run black for code formatting

* Do not cache index.html
2020-07-01 14:35:44 -07:00
Timmy Huang 2b0c4692ba e2e-add-more-logs (#1598) 2020-06-30 21:27:38 -07:00
bmccandless 5015c5ca3f Return float32 for embedding data type (#1597) 2020-06-30 14:16:55 -07:00
Bruce Martin 8898a9817c protect against non-integer array indexing (#1595)
* do not assume non-iteger array bracket access will return undefined

* revert to spec
2020-06-29 09:37:59 -07:00
Timmy Huang b79f09c550 FIX-test-target-closed: remove jestPuppeteer.resetBrowser() (#1592) 2020-06-26 10:02:25 -07:00
Timmy Huang 52a3f8b85a disable-puppeteer-dumpio (#1591) 2020-06-25 19:52:59 -07:00
Matt Weiden 152c07a28e Change default branch from 'master' to 'main' (#1589)
The default branch has been changed. This PR cleans up references to
`master` and fixes links that would otherwise be broken.

For more background see the following references:
* https://www.independent.co.uk/life-style/gadgets-and-tech/news/github-master-slave-slavery-whitelist-language-inclusive-a9568576.html
* https://tools.ietf.org/id/draft-knodel-terminology-00.html
2020-06-25 14:04:30 -07:00
Matt Weiden 0fd3d4b8f4 Add default environment override back to common.mk (#1586)
* As part of https://github.com/chanzuckerberg/cellxgene/pull/1548 we
  accidentally removed the part of the "get_or_else_dev_env_default"
  function that allowed users to override the environment variables.
  This commit adds that back.
* When environment.default was changed from a shell script file to json,
  the shell commands used to parameterize DATASET were not evaluated.
  This commit fixes this issue as well.
2020-06-25 08:25:29 -07:00
83376627e8 1510-smoke-test (#1548)
* 1510-smoke-test

* config default

* update tests

* update test config

* fix linter errors

* more comments

* address comments

* use npm install in push_tests.yml

* use environment.default.json

* adding docs

* Take care of @mweiden's nits

* Save screenshots in the __tests__/screenshots/ directory

* typo

* docs

* Add chart tests (#1580)

* merge tests

* check if bin creation returned null before rendering charts (#1576)

* check if bin creation returned null before rendering charts

* refactor chart rendering into functions (#1577)

* little fixes from PR

* reintroduce fix to check for null values

* change getAllByClass to return element

* slice instead

* new stackedbar test

* feedback-1573-test (#1579)

* feedback-1573-test

* enable whole test set

* revert tests

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

* tweak test to actually render chart

* include snapshot

* remove async

* fix getAllHistograms

* properly grab id

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>

Co-authored-by: Matt Weiden <538456+mweiden@users.noreply.github.com>
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
2020-06-24 11:45:39 -07:00
bmccandless e22e671f10 Return 404 when the dataset does not exist (#1581)
Also in this PR, restructure the exception handling.

fixes #1566
2020-06-23 17:14:42 -07:00
Severiano BadajozandTimmy Huang 9e7ecfa279 check if bin creation returned null before rendering charts (#1576)
* check if bin creation returned null before rendering charts

* refactor chart rendering into functions (#1577)

* little fixes from PR

* reintroduce fix to check for null values

* change getAllByClass to return element

* slice instead

* new stackedbar test

* feedback-1573-test (#1579)

* feedback-1573-test

* enable whole test set

* revert tests

Co-authored-by: Timmy Huang <tihuan@users.noreply.github.com>
2020-06-23 11:59:39 -07:00
Severiano Badajoz 0e28df0bd6 Fix Truncation styling issues (#1558)
* fix color inheritance

* tweak width constants

* update snapshot

* Trigger workflow
2020-06-16 10:48:28 -07:00
Donald Paul Herman d6a96e9c29 Improve Lasso 'will close' visual feedback. (#1550)
* Decreased the scope of polygonToPath and distance variables.  Added a
new variable for the lasso path color.  Removed the color setting from
handleDragStart, since this is already set in handleDrag.  Added a
closePathColor "#bb2f00" which is the complementary of lasso path color.
When a lasso can close, the color will change from blue to red.

* Fix some linter issues.

* Fixed some linter issues.

* Fixing some linter issues.

* Changed lasso open and closed to BLUE5 and GREEN5 respectively.
2020-06-16 11:48:29 -04:00
Severiano Badajoz 23619010c3 Componentize bar charts (#1557)
* create miniHistogram Component

* use MiniHistogram

* create MiniStackedBar Component

* Use MiniStackedBar

* update graphs on colorAccessor change

* Move bin creation out of miniHistogram

* rename expressionLabel

* breakout stackedbar bin creation

* Trigger workflow

* move components to individual folders

* rename constant
2020-06-15 11:32:55 -07:00
Colin MegillandSeveriano Badajoz 77ffa0712e If min / max are the same, hide histo + legend, show value (#1554)
* hide legend, histo single value

* display block

* extra check for domain

* Update client/src/components/continuousLegend/index.js

Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>

* readability on variables, use clipped ranges

Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
2020-06-11 16:27:02 -04:00
mattcai d6c606b72e minor typo fix (#1553) 2020-06-10 12:57:27 -07:00
Bruce Martin bc7a712048 refactor categorical controls state (#1549)
* refactor categorical controls state

* lint

* fix race condition in tests

* fix typo

* add missing update on subset

* remove obsolete code

* update jest and puppeteer major version; update all minors

* update when label changes

* remove lint from tests; increase timeouts in e2e tests

* changes in response to PR review

* lint

* more PR comment changes

* more PR comment fixes

* lint

* more PR comment resolutions
2020-06-10 12:56:38 -07:00
Severiano Badajoz f063708b33 Add linting rules around ESLint directive comments (#1551)
* add eslint-plugin-eslint-comments and require descriptive configuration

https://eslint.org/docs/user-guide/configuring#using-configuration-comments

* update ESLint

* audit ESLint disables

* update ESLint and related dependencies
2020-06-08 17:31:25 -07:00
Severiano Badajoz 4e96847032 Create Truncation Component (#1500)
* remove ESLint rules

operator-linebreak is no different then default
quotes is overwriting prettier config

* add string-pixel-width dep

* don't lint-staged src, only staged

* add widthMap

* create Truncate component

* refactor in truncate component

* add font load checking

* remove font-family styling

* render Truncate's child instead of creating own component to render

* refactor to use Truncate component

* add span back

* support children

* remove maybeTruncateString

* sub in Truncate component

* add bold prop

* accurately compute largest possible string

* remove logs

* tweak truncation method

* memoize comp function

* explain disable

* fix bugs w/ abs/floor

* tweak widths

* tweak widths

* fix font size

* remove border

* move test-id

* attempt css solution

* Revert "attempt css solution"

This reverts commit aac4d8a6f6.

* CSS solution v2

* remove string-pixel-width

* remove widthsMap

* remove dead code

* remove "data-truncated" as it is always true

* tweak label width

* fix e2e tests

* remove testing string

* e2e annotations tweaks

* correct snapshot

* remove resolves

* check for labels

* add test-id

* format fix

* update snapshot

* fix color

* pull constants out where available
2020-06-05 10:14:42 -07:00
bmccandless 99d004d1f0 add config option to handle multiple dataroots (#1531)
#1513
2020-06-04 19:29:37 -07:00
Matt Weiden df6b42f5d6 Update client development documentation (#1504)
Update documentation
* on installing the server
* on how to get nodejs hot-reloading to work
* on running standard and annotations smoke tests with hot-reloading
2020-06-04 12:34:46 -07:00
Matt Weiden 101f446f8d Add .gitignore file added by elastic beanstalk (#1506) 2020-06-03 09:14:24 -07:00
Bruce Martin 2ba4944f5c initial bug fixes and test improvements for the matrix refactor (#1503)
* initial bug fixes and test improvements for the matrix refactor

* lint
2020-06-02 09:47:40 -07:00
bmccandless 76523d4f32 sparse column shift encoding. (#1502)
Many of our matrices are log normalized, which tends to eliminate
the number of non zero values (if there were any).  This prevents
the matrix from being stored as a sparse matrix.  The solution here
is to use a simple transformation to make it sparse again.  The most
common value from each column is subtracted from that column.  These
values that were subtracted are saved in an array called X_col_shift.

The cellxgene code needs to understand how to undo the transformation when
operating over the X matrix.

- added script to create a synthetic dataset for testing
- added a script to convert an existing CXG dataset to a sparse CXG dataset
2020-06-02 08:23:52 -07:00
bmccandless d0577b94af Return an empty matrix if no rows or columns are selected (#1501)
Return an empty matrix if no rows or columns are selected

Fixes #1499
2020-05-29 14:47:46 -07:00
bmccandless f7585eef1e Support for sparse tiledb arrays for the X matrix (#1496)
Support for sparse tiledb arrays for the X matrix

1. cxgtool can now output sparse matrices
2. cxg_adaptor and diffexp_cxg updated to handle sparse matrices
3. added a test in test_diffexp to test sparse diffexp and get_X_array
2020-05-28 18:36:02 -07:00
bmccandless 030eea1898 Update run_diffexp.py (#1494)
You can now select labels within groups instead of random rows.
2020-05-26 16:06:33 -07:00
Matt Weiden 51758a0be9 Remove the trusted types requirement (#1495)
There seem to be breaking changes in Chrome that are causing this to
fail. We've weighed the risk of disabling the feature with the issues
we've encountered using it and decided to disable it for now.
2020-05-26 12:31:56 -07:00
Severiano Badajoz 271e7a1e76 Fix prettier related ESLint issues (#1493)
* add quotes

* add prettier dev-dep

* run prettier on src

* add eslint-prettier-plugin to install
2020-05-26 09:33:59 -07:00
bmccandless 4d100d4507 Upgrade to tiledb 2.0 (#1485)
* Upgrade to tiledb 2.0
2020-05-21 14:55:11 -07:00
Severiano Badajoz 889b582538 ESLint tooling and enforcement(#1492)
* add eslint-prettier

* add lint-staged

* ignore .eslintcache

* remove unused import

* attempt test change

* Revert "attempt test change"

This reverts commit 78d0e31d41.

* attempt to add error

* Revert "attempt to add error"

This reverts commit a5885c0942.

* add error

* create config

* add comment

* tweak glob

* Revert "add error"

This reverts commit 3187522396.

* add python error

* Revert "add python error"

This reverts commit d0b116ed15.

* lint on push + PR

* Revert "add comment"

This reverts commit efa4eb6676.

* add prettier rules to ESLint

* remove lint-diff targets
2020-05-21 14:27:49 -07:00
Severiano Badajoz 18da8911a0 Change appearance of centroid labels of unselected values (#1491)
* add opacity change on selection state

* change overlay z-index to capture pointer

* https://www.youtube.com/watch?v=xrg-RgF5F8o

* render null if no coloring by continuous

* run lint on push + pr

* revert commit

wrong branch
2020-05-21 09:22:10 -07:00
Matt Weiden 8d762e59ca Deduplicate merged gene list in subsetAndResetGeneLists (#1490)
Fixes https://github.com/chanzuckerberg/cellxgene/issues/1486
2020-05-20 15:35:27 -07:00
Matt Weiden efdae22fbe Lengthen e2e test timeout to account for page load (#1488) 2020-05-20 14:55:43 -07:00
Severiano Badajoz b06b08ce33 Fix centroid label + continuous color-by interactions (#1484)
* add case to removing labels

* add disabled state to centroid button

* fix centroid test button clicking order

* don't accidentally un-toggle first color
2020-05-20 14:02:10 -07:00
Colin Megill bc5b01da0f Break out anno plus button (#1473)
* break out plus button

* remove menu item

* padding, remove icon

* icon padding
2020-05-20 16:22:29 -04:00
Severiano Badajoz ce819383af re-enable front-end lint test (#1483)
* change lint to only src

* reenable lint all
2020-05-19 15:46:04 -07:00
Bruce Martin 2992d17c1d add more detasets to hosted site (#1481)
* add more detasets to hosted site

* fix build
2020-05-19 14:24:27 -07:00
Severiano Badajoz c34a68304e remove all linting errors on client/src (#1463)
* run eslint --fix

* camelcase

* camelCase config part 1

* part 2

* part 3 - removing subscripts

* fix "class-methods-use-this"

* fix "class-methods-use-this"

* fix eslint ignores

* add eslint ignore for set state in update

* reformat comments to appease eslint

* add a11y features

* sort-comp fix

* a11y fix

* add ignore for set state in update

* add a11y htmlFor

* remove unused toast

* remove unnecessary bind

* add ignore for set state in update

* add rel="noopener noreferrer"

Using target="_blank" without rel="noopener noreferrer" is a security risk: see https://mathiasbynens.github.io/rel-noopener

* use arrow function to bind

* remove unused definitions/declarations

* prettier

* remove unused state

* add comments to empty catch blocks remove curly brackets

* escape '

* use eqeqeq

* switch from default export

* remove ignore log

* remove static

* fix import

* revert subscripting config

* clean-up

* remove unnecessary subscript

* fix new errors from master

* change category click handler to a class property

* fix camelcase changes that slipped by

* unused import

* Fix newly introduced ESLint errors from addGenes
2020-05-19 12:38:29 -07:00
Colin Megill 5fc76edf2d Factor adding genes into own component (#1480)
* factor out add genes to own component

* correct import
2020-05-19 11:41:49 -04:00
Matt Weiden e21997799c Upgrade python requirements to click>=7.1.2 (#1472)
6.7 does not have the `hidden` flag used in the code. Users building the
app with an older version of click within the current range specified by
requirements.txt may fail.
2020-05-14 10:04:28 -07:00
Matt Weiden 730410c5e1 Autoformat python to fix lint errors (#1470)
* Autoformat python to fix lint errors

* Fix lint errors not caught by black
2020-05-12 13:19:38 -07:00
Matt Weiden e55595cc55 Fix smoke tests to work with remote deployments of cellxgene (#1469)
* Add test for terms of service

* Add workaround for chromium CSP require-trusted-types-for error
2020-05-12 13:01:57 -07:00
Bruce MartinandMatt Weiden c1bf491a96 Hooks for sentry integration (#1460)
* add sentry webpack plugin

* allow override of webpack config

* work around cheerio inability to parse jinga templates

* webpack can not minify jinja templates

* allow script injection to specify other attributes

* allow script injection to specify other attributes

* Adjustments to make plugin systems work

* Add sourcemaps for javascript in prod webpack

* Update .gitignore

* Fix spelling errors

Co-authored-by: Matt Weiden <538456+mweiden@users.noreply.github.com>
2020-05-08 15:59:56 -07:00
Colin Megill 9026e0ce41 Sci notation on continuous legend (#1450)
* sci notation on continuous legend

* significant digits
2020-05-07 12:59:10 -04:00
Colin Megill 7ec9bb92f7 Create new label button overflow (#1459)
* Tighten up copy

* amp;
2020-05-07 12:58:21 -04:00
Matt Weiden 888f730526 Refactor CSS and React logic for layout (#1424)
* Refactor CSS layout and react logic for layout

Fixes https://github.com/chanzuckerberg/cellxgene/issues/1022

* Menubar should wrap inside middle pane instead of overlapping left
  sidebar when window is scrunched
* cellxgene should have a minimum width of 1240px

1. Replace absolute positioning and dimension calculation with css grid
2. Use flexbox for wrapping menubar buttons

* Middle pane (graph) can calculate its own size
* Removing components calculating their size/position relative to
  eachother increases modularity, decreases use of global variables
* Improved some scrollbar behavior
* Removed responsive reducer, propagating window size to components
  triggers unnecessary events and encourages breaking modularity; doing
  this made some components state agnostic

Reference: https://css-tricks.com/snippets/css/complete-guide-grid/

* Reposition the continuous legend

* Small fixes

* Respond to feedback from @colinmegill

* Respond to feedback from @colinmegill

Add more documentation on the renderGraph method.
2020-05-06 13:46:13 -07:00
Matt Weiden a7d641ea6b Provide ToS button identifiers for smoke test to use (#1462) 2020-05-06 13:45:09 -07:00
bmccandless f49507f18a app_config, fix bug with list/tuple command line arguments. (#1464)
* app_config, fix bug with list/tuple command line arguments.

There was a error caused by pyyaml using lists, and click using tuples.
Now tuples are automatically converted to lists when the config is
updated.
2020-05-06 12:00:02 -07:00
Matt Weiden 5947306ca0 Add server plugin system (#1447)
* Add server plugin system

Plugins are optional modules loaded at runtime. Specification:
* Plugins are loaded from the server.plugins module (directory
  server/plugins)
* The import_plugins method is run as part of the initialization of the
  server module in __init__.py

* Add plugins to the EB build process

* Remove bit of dead code

* Respond to feedback from @bmccandless
2020-05-05 17:05:42 -07:00
Colin Megill 67d7b8160f was 27, 41 was too long, 33 seems ok (#1458) 2020-05-05 15:47:18 -04:00
Colin Megill 630b59d4a7 remove popover (#1456) 2020-05-05 14:34:11 -04:00
bmccandless 55a7967001 Add a process for specifying exact requirements for an EB deployment (#1451)
Add a process for keeping specifying exact requirements for an EB deployment
2020-05-05 10:51:02 -07:00
bmccandlessandSeveriano Badajoz 61ca75e846 hosted, update order to look for config file. (#1452)
* hosted, update order to look for config file.

The app now uses a local config.yaml file bundled with the artifact
(if present), if it exists, then looks in the CXG_CONFIG_FILE
environment variable.  This is the reverse of previous behavior.
The purpose of this change is to move away from using the
config file on s3, since that could lead to problem where an older
version of the app uses a newer version of the config.

Also in this PR:
1. Changed documentation around dataroot, to describe the posibility of using lustre.
2. Added a few improvements around the secret manager region name.  If we use lustre for dataroot and a local config file, then we will no longer be able to
auto determine the region for the secret manager.  I plan to start using the
environment variable option for hosted cellxgene.

* small edit to README

Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
2020-05-05 10:50:28 -07:00
Matt Weiden 1ee76826c7 Pin tiledb version 0.5.9 (#1453)
There is a regression in 0.6.0
2020-05-05 10:03:57 -07:00
Bruce Martin f42f5151a6 Refactor build and CSP headers for Safari compat (#1442)
* add unsafe-inline directive to style-src

* debugging - turn on csp reporting

* revert reporting only csp

* do not inline JS and CSS in build

* enable HTTPs only when in production mode

* remove debug printf

* fix clean target

* revert force_https removal
2020-05-04 12:47:35 -07:00
Severiano Badajoz 6cccc41c0f run prettier(2.0.5) (#1438) 2020-05-04 10:26:42 -07:00
bmccandless b255e32548 Add a more consistent way to customize the eb deployment (#1446)
* Add a more consistent way to customize the eb deployment

Plus a few minor bugfixes
2020-05-04 09:52:03 -07:00
Matt Weiden a17fff83cf Return empty colors for .cxg v0.0 files (#1441)
* Return empty colors for .cxg v0.0 files

Fixes https://github.com/chanzuckerberg/cellxgene/issues/1440

The CxgAdaptor.get_colors method currently assumes that the .cxg file has
cxg_group_metadata. As a result, the /api/v0.2/colors endpoint always fails for
.cxg v0.0 files.

* Add test fixture
2020-04-30 17:21:20 -07:00
Matt Weiden 40fbc42b13 Cache crossfilter when embedding type is changed (#1439)
Fixes https://github.com/chanzuckerberg/cellxgene/issues/1433

When selection is deselected the world is reset to the universe the
crossfilter is reset to that in the resetCache, including the embeddings
in the layout_XY dim.  However, the embedding selection stays the same.

If the embedding selected is not the default, the embedding shown to the
user will be different than the embedding layout_XY in the crossfilter,
causing lasso selections to be made against the wrong embedding
coordinates
2020-04-30 13:48:14 -07:00
bmccandless cd821a0344 Update the EB build process to package in scripts (#1434)
This is implemented specifically to handle the google analytics
script for hosted cellxgene
2020-04-28 14:38:35 -07:00
bmccandless e495c571c3 Change the EB log level from DEBUG to INFO (#1435)
The log level was mistakenly set to DEBUG, which sent out tons
of less than useful messages.  INFO works much more useful,
and much less verbose.
2020-04-28 14:37:41 -07:00
Bruce MartinandColin Megill 05fcdaf93c Revised terms and privacy consent dialog, analytics hooks (#1426)
* revised terms and privacy consent

* reorg code

* fix conditional

* Overlay reflects un-dissmissable state

* add inline scripts, and consent callback

* add csp_directive config hook

* revert config.yaml

* fix logic error

Co-authored-by: Colin Megill <colinmegill@gmail.com>
2020-04-28 14:21:53 -07:00
Madison Dunitz 666e6d9849 Add Deployment hook (#1430)
* test deployment hook

* remove test branch

* update heroku review deployment
2020-04-27 13:01:31 -07:00
Matt Weiden 546e272a60 Add user-defined category-label colors (#1402)
* Add user-defined category-label colors

Fixes https://github.com/chanzuckerberg/cellxgene/issues/1152

As described in https://github.com/chanzuckerberg/cellxgene/issues/1307

* Respond to feedback from @bkmartinjr in nodejs

* Respond to feedback from @bkmartinjr in python

* Add tests to the server module

* Autoformat python, run linter

* Make colors_get error handling specific

* Respond to feedback from @bkmartinjr

* Respond to feedback from @bkmartinjr

* Fix whitespace

* Fix python lint errrors

* Update documentation

* Add --disable-user-colors option to launch and cxgtool.py

* Fix python formatting

* Rename '--disable-user-colors' to '--disable-custom-colors'
2020-04-26 22:52:57 -07:00
Bruce Martin 3c191c3f93 add dsuid 25 (#1429) 2020-04-26 07:53:49 -07:00
Eduardo LopezandMadison Dunitz 565a3f148d [feature] Prototype a Heroku deployment of hosted cellxgene (#1427)
* hello world

* stuff

* successful build

* updates"

* maybe a basic example

* simplify

* reamde into dockerfile

* some more stuff

* Release procfile

* package.json at top levle

* don't release in procfile for now

* more package.json stuff

* copy assets

* merge master

* not in the relase phase

* revert not necessary

* pin gunicorn version

* reset common.mk

* Update package.json

Co-authored-by: Madison Dunitz <dunitzm@gmail.com>
2020-04-24 12:44:38 -07:00
Bruce Martin d31b7dc797 clamp brush selection range to data range (#1423) 2020-04-24 09:24:01 -07:00
Bruce Martin 735eb11eb7 refinments to csp (#1422) 2020-04-23 09:54:09 -07:00
Matt Weiden a2047b90ae Make the URL hit by jest smoke tests configurable (#1420)
* Make the URL hit by jest smoke tests configurable

* Fix typo

* Make names consistent
2020-04-22 15:23:11 -07:00
Bruce Martin 9b12b729c9 more data (#1419) 2020-04-21 18:22:52 -07:00
Bruce Martin 8742f9094e cleanup (#1411) 2020-04-20 09:56:26 -07:00
Severiano Badajoz 8b59755157 increase category label max length (#1394) 2020-04-20 09:48:47 -07:00
Bruce Martin cbcd3f4f8f ensure crossfilter index dimensions match data (#1409) 2020-04-19 08:44:46 -07:00
Bruce Martin 5dc40158c4 disable eslint in github workflow (#1412) 2020-04-18 09:06:05 -07:00
Bruce Martin 55ef1448e8 CSP content hashes (#1406)
* remove duplicate content-type header

* plumbing to compute CSP content hashes

* add logging of missing CSP hashes

* convert sub-class init protocol to static

* factor function

* lint
2020-04-17 11:42:09 -07:00
Bruce Martin d22300e3db fix typo in cxgtool bool handler (#1404) 2020-04-17 11:11:14 -07:00
bmccandless 03a75ec566 s3 listings were cached, preventing new datasets from being accessible (#1408)
Fixes #1407
2020-04-17 10:14:24 -07:00
Matt Weiden 58e4c2489b Auto format python, make sure it passes lint checks (#1401) 2020-04-16 08:07:10 -07:00
bmccandless 7e7ed74b92 Provide a hook into the AWS Secret Manager for the flask secret key (#1398)
Also, the secret manager required a region name, so there was some
refactoring around how regions are handled.

Fixes #1239
2020-04-15 14:33:40 -07:00
Matt Weiden 95ade476e8 Add end of line marker to lint diff regexp (#1400)
This prevents json files from being linted
2020-04-15 13:46:36 -07:00
Colin Megillandbkmartinjr ea434fc46f Handle loading state when rendering categories with one label (#1393)
* refactor & loading state

* truncate

* fix minor PR review issues

* lint

Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
2020-04-15 14:38:44 -04:00
Matt Weiden 7d4d360e52 Refactor developer convenience scripts (#1377)
* Cleanup the backend-dev convenience method

* Add the frontend_dev convenience method

frontend_dev is a soup-to-nuts convenience method for setting up the FE
development environment with node running a the client code on port 3000
with the a separate cellxgene package serving the API over port 5005 in
the background.

The script can be run from Finder.

* Update the developer scripts documentation

* Remove the 'test' make target in the client Makefile

Rationale:
* Given how long the smoke tests take to run, it is unlikely that
  developers will want to run all tests together.
* It is unlikely that developers will have set up the backend server
  properly for the tests to pass.
* Available commands should be safe-ish and not lend themselves to
  confusing errors.
* You can still group tests by concatenating them in a make command, as
  in `make unit-test smoke-test`.
* This target isn't used in any of our CI pipelines -- KISS.

* Some version of python3...

* Minor typos in docs

* Respond to feedback from @bkmartinjr

* Make adjustments so that DATASET path is predictable

* Simplify environment defaults a bit
2020-04-15 10:32:19 -07:00
Severiano Badajoz e5dfd6e8fa Add eslint steps to lint job (#1335)
* add lint-client and lint-diff-client targets

* add lint-diff and lint targets

* prettier

* add lint-diff call to lint task

* temp

* tweak lint-diff

* add lint for PRs and lint for master

* remove temp

* remove incorrect branches syntax, use github_ref

* pull all branches

* format

* proper target and comment

* create separate steps with conditionals

* fix indentation

* refactor lint->lint-server, introduce lint to lint all

* trade diff-index for diff, do check against base instead of master

* remove fetching all branches

* Revert "remove fetching all branches"

This reverts commit 26ce7a0f05.

* tweak comparison

* use local eslint

* add eslint  dep install

* temp

* grab only base

* simplify fetch

* add pull_request type trigger

* specify pushes only to master

* change conditionals to be based on event name

* Revert "temp"

This reverts commit 3d59134cc0.

* "branch" => "branches"

* create separate installation step

* change command based on os
2020-04-15 09:41:45 -07:00
Colin Megill 64bd908c25 Render categories with one label without checkbox (#1375)
* categories with 1 item rendered without checkbox

* break out into helper functions

* prettier
2020-04-13 23:04:23 -04:00
Bruce Martin a3df1a9f4a performance and correctness fixes to brushable histogram (#1390) 2020-04-13 20:03:46 -07:00
bmccandless 5c0b8c6296 Improve diffexp for tiledb (#1388)
* Improve diffexp for tiledb

- The rows from the A and B sets are gathered and processed at the same time.  In this
  way the matrix is only accessed once instead of twice for each tile.
- There is now a single thread queue that gets shared between all callers of the diffexp.
  This will slow down work if diffexp gets too busy.
- There is a target_workunit amount of work given to each thread.  Previously the
  workunit was (rows selected * width of tile), which could be small.  Now multiple
  column tiles can be combined into one workunit.  If the target is too small then
  thread and other overheads may reduce performance.  If target_workunit is too large
  then the size of the gathered sub matrix may take up too much memory.
- add configuration parameters (max_workers, cpu_multiplier, and  target_workunit)
2020-04-13 18:53:13 -07:00
Bruce Martin 136093d583 add GET routes for expression data (#1387)
* add GET routes for expression data

* fix comment typo
2020-04-13 11:28:35 -07:00
bmccandless 98c2a1359b Specialize diffexp for tiledb (#1384)
* Specialize diffexp for tiledb

This patch adds a new diffexp algorithm which is tuned for tiledb.
This algorithm was written by Bruce and is adapted here to plug into the
current framework.  The anndata_adaptor still calls the original
algotithm (which was move from diffexp.py to diffexp_generic.py).
The cxg_adaptor now calls the new diffexp_tiledb version.  Some
code is shared between the two.

This is part 1 of the diffexp for tiledb.  Further tuning and
global throttles are still needed.

A script to run and time diffexp with various options is also
added: test/run_diffexp.py.
2020-04-12 09:56:55 -07:00
bmccandless 5dcfcb5467 s3 region should have a single config param (#1386)
* s3 region should have a single config param

The s3 region can also now be automatically determined to further
reduce errors.

This patch also fixes a bug with order of handling the config params.
The tiledb config needs to be fixed before attempting to load
(need to handle_adaptor before handle_single_dataset)
2020-04-11 15:20:01 -07:00
bmccandless b3c26e7bc7 Allow diffexp for hosted cellxgene (#1385) 2020-04-11 13:15:26 -07:00
Bruce Martin bff5745139 disable SSL cert verification (#1374) 2020-04-10 12:58:51 -07:00
Bruce Martin cfacdb15a8 fix typo (#1379) 2020-04-10 12:58:30 -07:00
Bruce Martin 2d2a7c2108 bump cache control max_age to one week (#1380) 2020-04-10 12:58:09 -07:00
Bruce Martin 61b330b9b0 fix non-finite condition in mean/var calculation (#1381) 2020-04-10 12:52:02 -07:00
Matt Weiden 14021db239 Clean up main Makefile (#1376)
* Remove unnecessary make target alias

* Remove installing from build directory

* Update documentation
2020-04-09 17:35:49 -07:00
Bruce Martin 0398249a20 CXG performance improvements (#1371)
* initial kv cache

* add per-key locks

* comments

* memoize schema

* add missing initialization

* fix sever timing

* fetch only what is requested

* fix tests to not require strict ordering of columns

* clean up annotation request

* remove debugging print
2020-04-09 10:19:26 -07:00
Bruce MartinandColin Megill e2a12ba9bb diffexp limit UI and configuration (#1336)
* warning on maxCount for diffexp

* cleanup logging

* clarification

* make the limits configurable

* make diff exp limit work

* danger!

* remove debugging code

* fix merge with master

* fix unit tests

Co-authored-by: Colin Megill <colinmegill@gmail.com>
2020-04-09 07:13:41 -07:00
bmccandless d457988810 Update for app config (#1369)
fixes an issue with "cellxgene launch" which had a bad interaction between
command line parameters and config file parameters.
Now, the config files are applied first, followed by the parameters that
were provided in the command line.

There is also now a check that each of the config attributes is type checked.
2020-04-08 15:44:56 -07:00
Matt Weiden 1f94c71c22 Use sci notation for axis ticks abs(tick) >= 10000 (#1363)
Fixes https://github.com/chanzuckerberg/cellxgene/issues/1349

For more information see https://github.com/d3/d3-format

Note that does not _fully_ fix the issue described in #1349, but rather
makes the formatting issue far less likely. It is _still_ possible for
this to occur if the difference between two ticks in axes happes in the
a significant digit cropped by the scientific notation format
2020-04-08 09:45:32 -07:00
Bruce Martin b466d952f9 add config and init for server timings (#1368)
* add config and init for server timings

* add missing config error check
2020-04-07 15:08:57 -07:00
Bruce Martin bcacb75296 load embeddings in parallel (#1352)
* load embeddings in parallel

* correctly capture unclipped

* test

* another test

* add convenient copy assets target

* cleanup
2020-04-07 09:48:07 -07:00
Bruce Martin 3b341a7191 add hcl dataset (#1366) 2020-04-07 07:51:32 -07:00
Bruce Martin 6193ae4997 Cleanup front-end build and python module contents (#1350)
* cleanup build and module contents

* lint

* update chalk

* more cleanup

* fix unit test
2020-04-06 19:04:06 -07:00
Severiano Badajoz 4efabf5231 refactor to remove non-standard terms in centroid files (#1330)
* refactor to remove non-standard terms

* switch key/value -> label/coords

* fix missing parenth
2020-04-06 10:20:11 -07:00
Bruce Martin ffa95b5a55 update s3fs requirements (#1348) 2020-04-05 09:22:32 -07:00
Bruce Martin 0d9f24115c cziscience landing page (#1347)
* fix dead link in launch page

* first cut at landing page

* wording changes

* add additional refinements

* fixes from PR review
2020-04-03 16:30:02 -07:00
Bruce Martin de8033e551 correctly handle categoricals that are not strings (#1346) 2020-04-03 15:50:02 -07:00
Matt Weiden 4117050081 make install should fail if there's no build dir (#1344)
Currently, if there is no build directory, the `make install` target
will attempt to cd into the build directory, fail, and run pip install
-e . in the root directory anyway. This causes cellxgene to be installed
from the source tree instead of what the user would expect.

This commit changes the behavior such that the `make install` will fail
if there is no build directory.
2020-04-03 15:09:54 -07:00
Bruce Martin 7c56041b11 categorical type handling fix (#1342)
* fix numeric category conversion bug

* correctly compute categorical summaries

* lint

* remove debugging print
2020-04-02 14:44:07 -07:00
bmccandless 308ee64f30 Improvements to the matrix cache (#1340)
* Improvements to the matrix cache

- Add a timelimit for the matrix in the cache.
Once the timelimit is reached, the matrix can be removed.

- If a DatasetAccessError occurs, then remove the dataset
from the matrix cache.

Fixes #1322
2020-04-02 13:44:11 -07:00
Bruce Martin fa0164fdf6 use no-store cache control (#1337) 2020-04-02 09:20:35 -07:00
Bruce Martin c2a24ae0c6 add cache control headers (#1333)
* add cache control headers

* add config param check
2020-04-01 15:43:43 -07:00
Bruce Martin 33ce95ba09 work-around for column name encoding bug (#1324)
* add attribute cleanup

* fix typo

* handle mappings

* logging

* fix regex

* remove debugging printfs

* update masked characters

* fix typo

* add missing incr
2020-04-01 15:06:21 -07:00
Bruce Martin 708a5af039 Add fetch concurrency limit on obs annotation loading (#1318)
* add fetch concurrency limit

* add tests for PromiseLimit
2020-03-30 16:23:09 -07:00
Severiano Badajoz 033727632c add truncation to loading (#1320) 2020-03-30 15:48:52 -07:00
Bruce Martin d4ec976aaa update locust routes to match new server URL structure (#1321) 2020-03-30 15:09:45 -07:00
bmccandless 03d7a107bf Fix for s3 region name in data locator (#1316)
There is a small chicken and egg problem.
The config file could be in s3, therefore when using the DataLocator to
download the config file, we don't yet have an app_config object.
Adding a check to handle this case.
2020-03-29 09:20:34 -07:00
Bruce MartinandColin Megill c8f98917c5 tos prompt (#1313)
* tos toast

* finish ToS prompt

Co-authored-by: Colin Megill <colinmegill@gmail.com>
2020-03-28 22:31:19 -07:00
Bruce Martin 8fac40b6ae various fixes for s3fs use (#1312)
* various fixes for s3fs use

* lint
2020-03-28 22:26:03 -07:00
bmccandless 54a75ffd7e Add support to include static files into the eb deployment (#1305)
Mostly this is just instructions for how to do this,
with a small addition to the makefile.
This enables support for serving the about_legal_tos and about_legal_privacy
from the cellxgene server.
2020-03-26 15:03:37 -07:00
bmccandless 8c0480b0bc Added a config hook for secret key into the app. (#1304)
* Added a config hook for secret key into the app.

the server first looks in an environment variable,
then looks in a config file.

For the cellxgene launch app, a default key is used if none is provided.
For the eb app, a secret key must be provided.
2020-03-26 15:03:08 -07:00
Bruce Martin a398f85ccc turn off auto-reload in debug mode (#1301) 2020-03-26 13:34:56 -07:00
Bruce Martin 57938b487b remove static route redirects / bump static to top level (#1299)
* add another favicon rule

* catch only top-level dataset names

* add config flag to disable redirects

* remove static redirect, point all links to top-level /static

* remove extraneous argument

* remove httpd redirect
2020-03-26 10:45:10 -07:00
bmccandless 40c2e4c164 Change route to the datasets (#1302)
Previously is was /<dataset>/...
Now there is an extra levle:  /d/<dataset>/...
2020-03-26 10:35:17 -07:00
Colin Megill adf7010f50 Add TOS and Privacy url via config (#1300)
* add tos and privacy url via config

* typo

* readd condition

* param
2020-03-26 11:33:41 -04:00
bmccandless 91e17e64a5 Fixes for static_rewrite.config (#1298)
replace tabs with spaces (yaml parsing complained).

change to favicon.png.
2020-03-25 17:44:15 -07:00
Bruce Martin 26605049a4 Various hardening to REST routes (#1293)
* URL reweriting for static

* request size limits

* improve quotas, make tests work

* remove debugging code

* pass limits to front-end

* fix renaming boggle
2020-03-25 16:14:52 -07:00
bmccandless 7b53209ae3 Fix a deadlock when an exception is thrown during matrix validate/open (#1296) 2020-03-25 15:49:49 -07:00
Bruce Martin 674f14f9bd harden dataset name handling (#1297) 2020-03-25 15:37:06 -07:00
Matt Weiden e07ebf85e8 Small documentation fix in prepare (#1292)
See sample of current behavior below:
```
venv❯ cellxgene prepare example-dataset/pbmc3k.h5ad
[cellxgene] Starting CLI...
Error: [cellxgene] cellxgene prepare has not been installed. Please run
`pip install cellxgene[prepare]` to install the necessary requirements.

~/workspace/cellxgene mweiden/446-custom-color-palette*
1 venv❯ pip install cellxgene[prepare]
zsh: no matches found: cellxgene[prepare]
```

Fix:
Wrap cellxgene[prepare] in single quotes.
2020-03-25 14:38:07 -07:00
bmccandless 5ec66c5b0e Avoid race condition in the matrix cache handling. (#1280)
* Improved fix for matrix cache handling.

During the MatrixDataCacheItem acquire function there was a
time when the write lock was released and the read lock was taken.
During that time, the dataset could have been deleted, later
result in the MatrixDataCacheManageri data adaptor returning None.

The solution is to demote the writer lock to a reader lock instead
of unlocking and relocking.

Also, when a the cache needs to delete an entry, the delete
is done outside the MatrixDataCacheManager lock.   This operation
only requires the write lock for the MatrixDataCacheItem.

Fixes #1255
2020-03-24 12:23:47 -07:00
bmccandless bea1836386 hosted cellxgene, add an environment variable to specify the config file (#1288)
Fixes #1272
2020-03-24 11:43:35 -07:00
Bruce Martin 752b9e4ab3 CORS and CSP headers (#1286)
* do in-app compression only for CLI

* CORS and CSP headers

* lint

* add --debug to targets

* lint

* fix botched merge with master
2020-03-24 08:05:37 -07:00
Bruce Martin d196cbc8f6 do in-app compression only for CLI (#1285) 2020-03-24 06:36:40 -07:00
Bruce Martin 96b092f0eb fix health root exception (#1283)
* fix health root exception

* remove extraneous lambda
2020-03-23 15:13:32 -07:00
bmccandless 785bf5b5bc update for the legal configuration options (#1281)
* Configuration for legal information
2020-03-23 13:25:17 -07:00
Severiano Badajoz b249df9036 add/fix label tooltips (#1278)
* Move tooltip to right

* Change function to only return truncated string

* add tooltip to category label

* use position instead of PopoutPosition
2020-03-23 10:28:54 -07:00
Bruce MartinandMatt Weiden d99b84ba09 prepare - work around anndata bug (#1260)
* work around anndata bug 344

* fix accidental cut and paste error

* Use modified make_index_unique function

Temporarily copy code from https://github.com/theislab/anndata/pull/345
until the issue is resolved and released.

* Add notes and test for make_index_unique

* Lint fix

* Format python

Co-authored-by: Matt Weiden <538456+mweiden@users.noreply.github.com>
2020-03-22 12:27:59 -07:00
Bruce Martin db7a485796 tighten up error reporting (#1269)
* black reformat

* tighten up error reporting

* lint

* fine tuning

* additional improvements in exception handling

* lint

* include exception and traceback in log

* fix typo
2020-03-22 09:55:47 -07:00
bmccandless 8180be83b8 Introduce a config file to cellxgene (#1264)
* Introduce a config file to cellxgene

The config file format is in yaml.  The default config is located
in server/common/default_config.py.  A user may create a yaml file
that contains a subset of these fields.  It can be used during cellxgene
launch, or for hosted cellxgene.

The code has also been refactored.  Much of the logic to check arguments
has moved from launch to app config.

It is now possible to set the tiledb context parameters using the config
file.  Other feature will soon be handled in a similar way.
2020-03-22 09:34:11 -07:00
Bruce Martin 1351c8f724 restrict methods on all endpoints (#1271)
* restrict methods on all endpoints

* lint
2020-03-22 08:23:28 -07:00
Colin Megill c4731d52e2 target blank (#1254) 2020-03-20 20:07:51 -04:00
Bruce Martin de006f8f91 initial implementation of a health check route (#1262)
* initial implementation of a health check route

* remove debugging code

* lint
2020-03-20 14:21:32 -07:00
Bruce Martin 040dbd3a95 refine load test (#1257) 2020-03-20 14:09:12 -07:00
Bruce Martin 8f09a4b1bc JS package fixes (#1248)
* Remove accidental package install from build

* remove accidental package install
2020-03-18 19:00:28 -07:00
bmccandless e4bf65c54a Improve hosted cellxgene (#1234)
* Improve hosted cellxgene

 - option to turn off the test index page, or supply a page for redirect.
   For EB, The default is to return 404.  For cli launch, the default is the test page.

 - option to select which matrix types are allowed for multi dataset servers.
   For EB, The default is CXG only.  For cli launch, the default is any matrix type.

 - Return early with an error response if diffexp is requested when not configured

 - Verified that reembedings and user annotations also return with an error response
   if used when not enabled.

TODO:  The new options cannot currently be set by the user.
I plan to add a configuration file where these and all other settings can be set.

 Fixes #1210 
 Fixes #1228  
 Fixes #1229
2020-03-18 16:21:03 -07:00
Bruce Martin 38c4ecac8c locust load test script (#1244)
* initial version of a locustfile driver

* README
2020-03-18 11:27:07 -07:00
bmccandless 8a733270d7 fixes for hosted cellxgene (#1227)
* Fix for favicon with --dataroot

* fix static assets in hosted cxg 

The web proxy at aws eb was not finding the static assets.

The solution here is very simple: just copy the directory
containing the static assets to the top level of the artifact.zip.

This is not really the ideal solution.  According to the AWS
docs you can make a mapping to the correct location in an
an ebextentions config file.  I tried this and many combinations but
was not able to get this to work following that pattern.

Since we control the construction of the zip file, the solution
here isn't bad, but it could probably be made better.
2020-03-16 09:47:01 -07:00
Matt WeidenandDonald Paul Herman 86cbe64ae8 Add color mapping to the bar chart (#1218)
* Add color mapping to the bar chart.

* Change histogram color generation from Viridis to InterpolateCool.

* Coloring of Histrogram based up the x axis instead of y axis.

Coloring of Histrogram based up the x axis instead of y axis.

* Respond to feedback from @colinmegill

* Only color histograms that are selected for colorby
* Add some small refactors to BrushableHistogram's componentDidUpdate

* Fix histogram coloring and binning

* Reuse binning functions from util/dataframe/histogram.js; this fixes
an issue with there being near-zero width bins
* Fix color mapping so that it matches the scale in the legend
* Do not attempt to plot bins if the calculated binWidth is zero; this
can happen if all values are the same
* Refactor the function that draws the histogram a bit

* Respond to feedback from @bkmartinjr

Co-authored-by: Donald Paul Herman <Donaldpherman@hotmail.com>
2020-03-14 15:24:52 -07:00
Bruce Martin 1e8d89f8f2 refinements for 0.15 (#1226) 2020-03-13 16:30:47 -07:00
Severiano Badajoz 9c2f57df39 bump version to 0.15.0 (#1222) 2020-03-13 13:11:12 -07:00
Bruce Martin 9bc83fdd02 update contributor process recommendations (#1213)
* update contrib process recommendations

* fix typos

* clarification

* fix build instructions

* updates suggested by Matt

* fix dead link
2020-03-13 11:41:30 -07:00
bmccandless bbfd2e5ac5 Add a simple annotations cache to the server (#1220)
Reading the user annotations had a performance problem.
The same file was read over and over.  Now, the last set of user
annotations are cached.
2020-03-12 20:06:44 -07:00
Bruce Martin f28bd52d79 fix performance regression caused by excessive rendering (#1219) 2020-03-12 15:05:08 -07:00
Bruce Martin 63f1f38f7c use AnchorButton so the tooltip dismisses (#1217) 2020-03-12 08:36:36 -07:00
Severiano Badajoz 3ba4cf4483 fix bug created from master rebase (#1216) 2020-03-11 15:20:20 -07:00
Matt Weiden a862f2b2a4 Further refine compatibility tests (#1215)
* Make sure apt is up to date before pulling hdf5

* Only install py dev reqs in cxg release vs anndata master test

* Don't need bu flag when using sed on ubuntu

* Don't re-install package reqs in python x anndata ver tests

* Minor documentation fix
2020-03-11 14:16:05 -07:00
Severiano Badajoz 3bf72829c2 pass empty coordinates when a continuous annotation is colored (#1212) 2020-03-11 13:01:14 -07:00
Severiano Badajoz 7cd9a0032a optimize centroid util (#1147)
* move unvarying evaluations outside of loop

* refactoring

* remove perf checks

* commenting

* minor fix + renaming

* small fix

* benchmarking

* merge master

* Revert pref checks

* renaming and comment

* remove redundant sets in Map
2020-03-11 12:35:42 -07:00
Sidney BellandMatt Weiden 9089fc98f2 annotations cli updates (#1190)
* Switch logic from `--annotations` to `--disable-annotations

* Rename `--annotations-file` --> `--annotations-input-file` and remove `experimental`

* update docs

* update makefile

* update tests

* Disable annotations on standard client smoke test

* Update docs/posts/annotations.md

Co-Authored-By: Matt Weiden <538456+mweiden@users.noreply.github.com>

* Update docs/posts/annotations.md

* Renaming

* Docs

* Update tests

* Pesky typo -_-

Co-authored-by: Matt Weiden <538456+mweiden@users.noreply.github.com>
2020-03-11 11:57:52 -07:00
Bruce Martin 8beeb57c96 Use DataLocator consistently on backend (#1208)
* allow DataLocator to accept another locator as init param

* migrate to DataLocator

* migrate to DataLocator

* lint

* migrate to DataLocator

* add check for erroroneous use of remote path and annotations

* lint

* revert default data location - now back go CWD

* remove unused import
2020-03-10 19:12:49 -07:00
Colin MegillandMatt Weiden 234f25b782 Conditionally truncate category string (#1206)
* maybe truncate string

* add string formatting to test

* correct import

* destructuring

* add maxlength

* test

* Respond to feedback from @bkmartinjr

Co-authored-by: Matt Weiden <538456+mweiden@users.noreply.github.com>
2020-03-10 18:14:26 -07:00
Bruce Martin 28ddb60b2f update JS deps (#1203) 2020-03-10 10:59:12 -07:00
Colin Megill 1a93068810 Prevent category collapsing on annotation edit, # of cells on add label button (#1204)
* collapse functionality

* expand collapse
2020-03-10 13:21:06 -04:00
Matt Weiden d8231a9a3b Fixes to compatibility tests (#1205)
* Unpin anndata ver in tests against anndata master

* Add branch config to work on push

* Add links to tests in GitHub Actions status badges

* Remove temporary branch name
2020-03-10 08:19:36 -07:00
Bruce Martin ffdcc24a40 remove pinned tables dependency (#1202) 2020-03-10 07:47:54 -07:00
Severiano Badajoz 023a73cab8 smoke-test centroids + graph overlay layer (#1189)
* add test labels

* prettier + add clcik return to clickOn()

* prettier + begin test

* finish label counting test

* add util to get coordinates of element

* add test id to labels

* add test to check overlay transform

* remove logs

* rename to match master
2020-03-09 17:01:22 -07:00
Bruce Martin 144b19c449 experimental re-embedding (#1186)
* first cut at re-embedding route and back-end support

* update and expand config route tests

* add scanpy_umap

* add reembedding to config route parameters

* front-end support for reembedding fetch and UI

* remove unused imports

* add loading state

* save reembedding in reducer state

* improve withColsFrom

* transmit reembed schema to client; pick unique embedding names

* display embeddings

* format

* lint

* spaces, tab size 2

* lint

* test hack for smoke-test race

* back out hack sleep

* add check for backed mode

* add unit test for reembedding

* lint

* hide re-embedding CLI param from help
2020-03-09 16:53:30 -07:00
bmccandlessandbkmartinjr b3e9719602 hosted cellxgene (#38) (#1200)
* early, non-working eb config

* hosted cellxgene

In this PR, contains scripts and instructions for deploying cellxgene
for AWS elastic beanstalk.  It supports the multi-dataset option.

The Makefile in the server/eb directory creates an artifact.zip
file, which can be deploy at AWS EB.

The server/eb directory contains:
  app.py - flask app to run the server
  Makefile - which creates an artifact.zip file which can be deployed.
  README.md - instructions for setting up and deploying the eb app.

* hosted cellxgene (#38)

In this PR, contains scripts and instructions for deploying cellxgene
for AWS elastic beanstalk.  It supports the multi-dataset option.

The Makefile in the server/eb directory creates an artifact.zip
file, which can be deploy at AWS EB.

The server/eb directory contains:
  app.py - flask app to run the server
  Makefile - which creates an artifact.zip file which can be deployed.
  README.md - instructions for setting up and deploying the eb app.

* Update how artifact.zip is created

prune the server/test and server/eb directories

* Remove debugging print statements

* fixes from review comments

* fix lint

Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
2020-03-09 12:08:07 -07:00
Matt Weiden bdd69160e2 Do not over-prune var data cache (#1198)
* Remove unused variables and imports

* Simplify conditional

* Fix typo

* Do not overprune var data cache

There is a bug in how the universe and world gene sets are constructed
and passed to `ControlsHelpers.pruneVarDataCache` that causes the var
data cache to be over-pruned. This commit fixes the issue.

Consider the following example from the node console:

```
❯ node
Welcome to Node.js v13.5.0.
Type ".help" for more information.
> new Set([1], [2], [3])
Set(1) { 1 }
```

What we really want is the set `Set(3) { 1, 2, 3 }`, which can be
constructed as:

```
> new Set([].concat([1], [2], [3]))
Set(3) { 1, 2, 3 }
```
2020-03-05 18:04:14 -08:00
Matt Weiden bddf66d62a Use blueprint "disable" property to indicate disabled buttons (#1197)
* Use blueprint "disable" property to indicate that colorby disabled

* Disable subset and reset buttons when action is not possible
2020-03-05 09:46:41 -08:00
Matt Weiden 7b77bf4bdd Make smoke tests faster, more stable (#1195)
* Refactor smoke tests & utils for conciseness/style

* Modularize test utilities
2020-03-04 16:11:32 -08:00
Severiano Badajoz cc890fe391 fix url launching on Heroku deployments (#1196)
* fix improper branch link

* set app to use dataset link

* temp repo change

* revert to master

* add options var

* tweak option order

* remove options config arg
2020-03-04 13:05:11 -08:00
Severiano Badajoz 62e944796b enable camera interaction on centroids (#1160)
* pass handleCanvasEvent to graphOverlayLayer

* dont pass handlecanvasevent if not interactive
2020-03-04 11:31:30 -08:00
Matt Weiden 1547a864c2 Disable ColorBy button for truncated categories (#1191)
* Disable ColorBy button for truncated categories

Fixes https://github.com/chanzuckerberg/cellxgene/issues/1156

For categories that have more than 100 labels we truncate the labels in
the UI, but still allowed users to ColorBy these categories. Coloring by
these categories can cause browsers to get bogged down.

This commit disables ColorBy for truncated categories.

* Minor documentation spelling and typo fixes

* Respond to feedback from @liaprins-czi

* Respond to feedback from @colinmegill and @bkmartinjr
2020-03-04 09:19:12 -08:00
Matt Weiden 05323ae643 Undo selection appends genes from differential expression to user gene list (#1183)
* Undo selection appends diffExp genes to user gene list

Fixes https://github.com/chanzuckerberg/cellxgene/issues/1171

Need: When a user performs a differential expression from within
a sub-selection (world) of the data and then resets the selection to all
cells (universe), the differential expression results are no longer
valid.

Approach:
* When the selection is reset, move the top (maxUserDefinedGenes
- len(userDefinedGenes) from the differential expression results to the
list of user defined genes
* Raise maxUserDefinedGenes to 25 to give users more room and
accommodate the extra genes transferred in from differential expression

Other commits:
* Choose different button icons
* Add diff exp genes to user defined genes on subset too
* Respond to feedback from @liaprins-czi and @bkmartinjr
2020-03-02 12:07:12 -08:00
bmccandless 87efcc0e99 Fix a few bugs from server refactor (#1188)
- Fixes #1185 port command line argument was not handle correctly
 - Fixes #1184 was not catching the "DatasetAccessError" exception.
2020-03-02 11:55:57 -08:00
Bruce Martin bf7d7342d5 Improve label picking (#1179)
* add simple error message helper

* port all label name pickers to use the new LabelInput component

* use pure components where possible

* cleanup

* more cleanup

* lint

* change new label prompt
2020-02-28 15:46:40 -07:00
Matt Weiden 1e4381ab7f Make the Clip button primary (blue) (#1181)
Fixes https://github.com/chanzuckerberg/cellxgene/issues/1101

I can't reproduce the active/inactive issue described in #1101

The only change remaining in #1101 is to make the clip button blue.
This commit makes that change.
2020-02-26 16:54:30 -08:00
Matt Weiden 8b0adcdfcc Add codecov configuration (#1180)
* Add codecov configuration

* Modify workflow
2020-02-26 14:57:46 -08:00
Severiano Badajoz 0043308b52 add centroid util unit tests (#1172)
* scaffold the test

* create tests
2020-02-26 12:16:13 -08:00
Matt Weiden 8a62c9f027 Add undo/redo smoke tests for annotations (#1175)
* Add undo/redo tests for annotations

Fixes https://github.com/chanzuckerberg/cellxgene/issues/969

... also refactor the tests for DRY.

* Add done()

* Make e2e annotations tests safer to concurrency

* Add data-testclass for save state.

* Simplify tests and make them dependent on save state
2020-02-26 12:10:03 -08:00
Matt Weiden ded35c7252 Spelling fix in Compatibility Test workflow (#1178) 2020-02-26 11:12:16 -08:00
bmccandless 46876e2fb1 Fix various bugs with CXG format at annotations (#1173)
- Enable testing for this path

 - Fixes #1166
 - Fixes #1167
 - Fixes #1168
2020-02-24 18:19:39 -08:00
Bruce Martin 7bc58bba2b undo/redo cleanup (#1165)
* fix refactoring error which disabled annotation file clearing

* fix undo behavior on add category label

* fix various undo/redo bugs

* remove logging

* further refinement of annotation undo/redo and actions

* address PR comment
2020-02-24 18:51:26 -07:00
Matt Weiden 5c70cc5bcd Add codecov for code coverage of python and javascript (#1170)
* Add codecov to Push Test workflow

* Empty commit

* Clear reports and tag each with flags

* Tag code reports by test

* Fix codecov tags

* One more fix
2020-02-24 17:13:16 -08:00
Matt Weiden 281154f591 Add exclusions to flake8 config (#1169)
This excludes directories which should not be linted. This
1. makes the lint test more representative of what is run in CI
2. is faster
2020-02-24 08:33:25 -08:00
Matt Weiden c7f2032dd7 Add user-generated annotations tests to the server (#1164)
* Add user-generated annotations tests to the server

Partially completes https://github.com/chanzuckerberg/cellxgene/issues/969

* Auto-format python code

* @skip_if: passing lambdas > than property strings

* Respond to feedback from @bkmartinjr
2020-02-23 15:32:13 -08:00
Bruce Martin fb1f0c6469 fix latent crossfilter bug (#1162) 2020-02-21 14:14:41 -07:00
Bruce Martin d2bae0c4f6 CXG creator and dataset parameters (#1163)
* remove redundant call to make_response (lint)

* initial commit of H5AD to CXG converter

* lint

* PR review suggested comments

* cxg adaptor now understands group metadata

* lint
2020-02-21 12:49:45 -07:00
bmccandless 907cc634f5 server refactor (#1140)
This PR contains a refactoring to make adding new features easier.

The new features include supporting the tiledb format, and the multi dataset application.

The refactoring includes

Simplifying the directory structure and files.
a class structure to handle annotations (currently one type: AnnotationsLocalFile).
a class to handle application configuration
a class structure to handle matrix data (currently AnndataAdaptor and CxgAdaptor). CxgAdaptor uses tiledb.
Algorithms that were previously dependent on the scanpy anndata object are now generalized to work with an abstract interface.
The multi dataset option is not fully supported yet, and so the option to use it is hidden.
Use "cli launch --dataroot ..."
To access this feature.

All combinations of app single dataset/ app multi dataset and AnndataAdaptor/CxgAdaptor work with all the features, such as annotations, ontologies, diffexp.
2020-02-19 10:22:35 -08:00
Bruce Martin 349c413d8b anndata X indexing & version compatibility improvements (#1157)
* revert MatrixProxy; replace with correct use of adata slicing

* work around 0.6 adata slicing bug

* fix incorrect var slice

* simplify slicing of X

* add warning about performance impact of anndata<=0.7

* lint and remove unused code

* improve comment

* lint

* correctly parse versions

* temp files should preserve file suffix if possible - anndata 0.7 compat

* update anndata dependency to 0.6.20

* resolve PR review comments
2020-02-19 09:57:51 -07:00
Matt Weiden c630be33df Simplify CI checks on git push, add nightly checks on cron (#1158)
* Switch push tests to Github Actions

* Add nightly tests

* Add anndata check

* Add test status badges

* Add anndata 0.6.22.post1 workaround

* Rename Nightly tests to Compatibility Tests

* Fix to anndata fix

* Turn on cron schedule

* Fix badge mardown in README.md

* Fix cron schedule
2020-02-18 11:57:19 -08:00
Matt Weiden d30087ca63 Add frontend smoke tests for annotations (#1154)
* Add smoke test for annotations features

* Do not save during annotations tests

* Fix botched rebase in dev guidelines

* Revert "Do not save during annotations tests"

This reverts commit f0bd970bb2.

* Respond to feedback from @bkmartinjr
2020-02-14 17:23:45 -08:00
Matt Weiden ac13b31e13 Collect all env vars in one, easy-to-find place (#1149)
* Collect all env vars in one, easy-to-find place

Past state:
* Default environement variables were stored in both client/package.json
and client/__tests__/e2e/config.js
* Constants that should have been linked--like the cellxgene server port
during testing--were repeated.

With this commit:
* All environment variables are parameterized
* All environment variables are packaged in default env files

* Move npm scripts to client Makefile

* Respond to feedback from @seve and @bkmartinjr
2020-02-12 12:50:48 -08:00
Severiano Badajoz 5340a5f951 graph overlay improvements (#1141)
* check to see if display state has changed

* add display state

* create onDisplayChange

* check to see if displaying anything and add opacity drop

* pass callback down to children

* add middle truncation to labels

* remove unused import

* add a bit of documentation

* make prop addition more clear

* rename onDisplayChange -> overlayToggled for readability
2020-02-12 10:56:41 -08:00
Bruce Martin e770db1e2c load annotations incrementally (#1107)
* load annotations individually

* fix type check to be more general

* update node CI version from 10 to 12

* node 11

* debug print node version

* travis node version to latest

* try nvm

* remove extraneous node_js statement

* remove node version debugging printf

* incrementally load all annotations and layout

* process annotations and layout as they are loaded

* fix tests

* sort categories incrementally

* incrementally build category view summary; add category loading spinner

* add spinner to continuous metadata

* configure undoable reducer

* incremental crossfilter creation

* improve busy layout

* more layout cleanup

* correctly reconcile categories in schema

* refine layout of lsb spinners

* more spinner layout work

* more spinner layout

* always load layout before obs annotations
2020-02-10 11:22:27 -08:00
Bruce Martin 1c9b9f6a08 js package dep update (#1145)
* js package dep update

* roll back start-server-and-test
2020-02-05 13:11:05 -08:00
Bruce Martin 3afc03b95b fix state prop bug with camera transform (#1138) 2020-01-31 12:13:35 -08:00
Sidney Bell a927d733c2 [WIP] Patch for anndata 0.7 (#1132)
* Fix JSON encoding bug introduced by anndata 0.7

* Add anndata csr and csc classes to allowed matrix proxy inputs
2020-01-28 16:12:43 -08:00
579 changed files with 53659 additions and 32852 deletions
+12 -2
View File
@@ -1,5 +1,16 @@
[bumpversion]
current_version = 0.14.0
current_version = 1.0.0
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<prerel>rc)\.(?P<prerelversion>\d+))?
serialize =
{major}.{minor}.{patch}-{prerel}.{prerelversion}
{major}.{minor}.{patch}
[bumpversion:part:prerel]
optional_value = release
values =
rc
release
[bumpversion:file:setup.py]
search = version="{current_version}"
@@ -12,4 +23,3 @@ replace = "version": "{new_version}"
[bumpversion:file:server/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
+8
View File
@@ -0,0 +1,8 @@
coverage:
status:
project:
default:
informational: true
patch:
default:
informational: true
+32
View File
@@ -0,0 +1,32 @@
---
name: "\U0001F41E Bug report"
about: Create a report to identify a bug in cellxgene
title: "[BUG]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Version (please complete the following information):**
- Desktop or hosted?:
- Browser (if hosted) [e.g. chrome, safari]:
- Version [e.g. 0.13.0]:
**Additional context**
Add any other context about the problem here.
@@ -0,0 +1,20 @@
---
name: "\U0001F4A1 Feature request"
about: Suggest an idea for this project
title: "[FEATURE REQUEST]"
labels: user request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,10 @@
---
name: "\U0001F9D0 Question/Clarification"
about: Ask a question or for a clarification
title: "[QUESTION]"
labels: question
assignees: ''
---
+67
View File
@@ -0,0 +1,67 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Scan"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '0 8 * * *'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+123
View File
@@ -0,0 +1,123 @@
name: Compatibility Tests
on:
schedule:
- cron: '0 8 7 * 2'
push:
branches:
- main
env:
JEST_ENV: prod
jobs:
docker-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Build docker image
run: docker build .
matrix-compatibility-test:
name: cxg:${{ matrix.cellxgene_build }} os:${{ matrix.os }} py:${{ matrix.python-version }} anndata:${{ matrix.anndata_version || 'latest' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# note: The `macos-latest` is latest Catalina version, and not Big Sur. So we explicitly ask for Big Sur (`macos-11`)
os: [ubuntu-latest, macos-latest, macos-11]
python-version: [3.6, 3.7, 3.8, 3.9]
cellxgene_build: [main, latest]
exclude:
# 3.6 no longer avail on Big Sur (`macos-11`)
- os: macos-11
python-version: 3.6
# no pypi build exists for macos+py3.9 and source install fails to
# install `tables` py pkg (a `scanpy` dependency), so we test py3.9
# only on ubuntu
- os: macos-11
python-version: 3.9
- os: macos-latest
python-version: 3.9
# add anndata pinned version test for subset of matrix configurations,
# in order to reduce matrix cross-product explosion
include:
- python-version: 3.8
cellxgene_build: latest
# TODO: dynamically use the literal version in requirements.txt,
# to avoid having to update this in manually in the future
# TODO: Do not bother running this if anndata latest version
# matches this pinned version, to avoid a redundant test
anndata_version: '==0.7.6'
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Cache env vars
run: echo "PIP_CACHE=`python -m pip cache dir`" >> $GITHUB_ENV
- name: Cache env vars (MacOS)
if: startsWith(matrix.os, 'macos')
run: echo "BREW_CACHE=`brew --cache`" >> $GITHUB_ENV
# FIXME: Only working for Linux
- name: Python cache
uses: actions/cache@v1
with:
path: ${{ env.PIP_CACHE }}
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Node cache
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Brew cache (MacOS)
if: startsWith(matrix.os, 'macos')
uses: actions/cache@v1
with:
path: ${{ env.BREW_CACHE }}
key: ${{ runner.os }}-brew-
- name: Install dependencies (Ubuntu Linux)
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libhdf5-serial-dev
- name: Install dependencies (MacOS)
if: startsWith(matrix.os, 'macos')
run: brew install hdf5
- name: Install cellxgene from `main` branch
if: matrix.cellxgene_build == 'main'
run: |
pip install -r server/requirements-dev.txt
make pydist install-dist
- name: Install cellxgene from latest release (pypi.org)
if: matrix.cellxgene_build == 'latest'
run: |
pip install --upgrade cellxgene
# install the additional dev requirements on top of what is in the
# cellxgene pip package, which are needed for testing, but otherwise
# keep same pip pkg versions as in the cxg release
sed -i'' -e 's/-r requirements.txt//' server/requirements-dev.txt
pip install -r server/requirements-dev.txt
- name: Install anndata version per matrix variable
run: pip install anndata${{ matrix.anndata_version }}
- name: Install node
run: make dev-env-client
# Run different types of test separately, to facilitate troubleshooting
- name: Unit Tests - client
run: make unit-test-client
- name: Unit Tests - server
run: make unit-test-server
- name: Smoke Tests
run: make smoke-test
# FIXME: Fails intermittently. See https://app.zenhub.com/workspaces/single-cell-5e2a191dad828d52cc78b028/issues/chanzuckerberg/cellxgene/2415
# - name: Smoke Tests with Annotations
# run: make smoke-test-annotations
+133
View File
@@ -0,0 +1,133 @@
name: Push Tests
on:
push:
branches: main
pull_request:
branches: "*"
env:
JEST_ENV: prod
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: |
git fetch --depth=1 origin +${{github.base_ref}}
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Node cache
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: |
pip install flake8
pip install black
cd client
npm install
- name: Format with black and lint with flake8
run: |
make lint-server
- name: Lint src with eslint
working-directory: ./client
run: |
npx eslint src __tests__
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Python cache
uses: actions/cache@v1
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Node cache
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: make pydist install-dist dev-env-server
- name: Unit tests
run: |
make unit-test-server unit-test-client
bash <(curl -s https://codecov.io/bash) -y .codecov.yml -k server -cF server,python,unitTest
cd client && ./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,unitTest
smoke-tests:
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Python cache
uses: actions/cache@v1
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Node cache
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: make pydist install-dist
- name: Smoke tests (without annotations feature)
run: |
cd client && make smoke-test
./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,smokeTest
smoke-tests-annotations:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Python cache
uses: actions/cache@v1
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Node cache
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: make pydist install-dist
- name: Smoke tests (with annotations feature)
run: |
cd client && make smoke-test-annotations
./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,smokeTestAnnotations
+18 -7
View File
@@ -3,6 +3,8 @@ node_modules/
# coverage
coverage
coverage.xml
.coverage
.nyc_output
.pytest_cache/
@@ -13,16 +15,18 @@ dist/
*.egg-info
# Environments
venv/
venv*/
cellxgene/
# client build
server/app/web/static/css/
server/app/web/static/img/
server/app/web/static/media/
server/app/web/static/fonts/
server/app/web/static/js/
server/app/web/templates/index\.html
server/common/web/static/*
server/common/web/templates/
server/common/web/csp-hashes.json
# eb build
artifact.dir
artifact.zip
customize
# Jupyter Notebook
.ipynb_checkpoints
@@ -39,7 +43,14 @@ npm-debug.log
__pycache__
*.DS_Store*
data
tags
myconfig.yaml
# Jekyll
docs/_site/
docs/Gemfile.lock
client/.eslintcache
# E2E Testing
ignoreE2E*
-28
View File
@@ -1,28 +0,0 @@
language: python
dist: xenial
sudo: required
node_js:
- 10
cache:
- pip
- npm
install:
- set -eo pipefail
- pip install flake8
- make pydist install-dist dev-env
jobs:
include:
- name: "Branch Tests 3.7"
python: "3.7"
script: make build-client lint unit-test
- name: "Branch Tests 3.6"
python: "3.6"
script: make build-client lint unit-test
- name: "Docker Build"
install: skip
python: "3.6"
script: docker build .
- name: "Smoke Tests"
python: "3.6"
script: make smoke-test
+1 -1
View File
@@ -1,3 +1,3 @@
We warmly welcome contributions from the community!
Whether you want to contribute ideas, requests, documentation, or code, you can get started by visiting our [contribution guide](https://chanzuckerberg.github.io/cellxgene/posts/contribute).
Whether you want to contribute ideas, requests, documentation, or code, you can get started by visiting our [contribution guide](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/contribute.md).
+2 -1
View File
@@ -4,7 +4,8 @@ ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
RUN apt-get update && \
apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev python3-requests && \
apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev python3-requests python3-aiohttp && \
python3 -m pip install --upgrade pip && \
pip3 install cellxgene
ENTRYPOINT ["cellxgene"]
+2 -2
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2013
Copyright (c) 2017-2021 Chan Zuckerberg Initiative
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
@@ -17,4 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+4 -2
View File
@@ -1,5 +1,7 @@
recursive-include server/app/web/templates *
recursive-include server/app/web/static *
recursive-include server/common/web/templates *
recursive-include server/common/web/static *
include server/requirements.txt
include server/requirements-prepare.txt
include server/converters/schema/hgnc_complete_set.txt.gz
include server/converters/schema/schema_definitions/*
+93 -84
View File
@@ -1,3 +1,5 @@
include common.mk
BUILDDIR := build
CLIENTBUILD := $(BUILDDIR)/client
SERVERBUILD := $(BUILDDIR)/server
@@ -5,7 +7,6 @@ CLEANFILES := $(BUILDDIR)/ client/build build dist cellxgene.egg-info
PART ?= patch
# CLEANING
.PHONY: clean
clean: clean-lite clean-server clean-client
@@ -15,42 +16,35 @@ clean: clean-lite clean-server clean-client
clean-lite:
rm -rf $(CLEANFILES)
clean-%:
cd $(*) && $(MAKE) clean
.PHONY: clean-client
clean-client:
cd client && $(MAKE) clean
.PHONY: clean-server
clean-server:
cd server && $(MAKE) clean
# BUILDING PACKAGE
.PHONY: build
build: clean build-cli
@echo "done"
.PHONY: build-client
build-client:
cd client && $(MAKE) ci build
.PHONY: build-cli
build-cli: build-client
.PHONY: build
build: clean build-client
git ls-files server/ | cpio -pdm $(BUILDDIR)
cp -r client/build/ $(CLIENTBUILD)
mkdir -p $(SERVERBUILD)/app/web/static/img
mkdir -p $(SERVERBUILD)/app/web/templates/
cp $(CLIENTBUILD)/index.html $(SERVERBUILD)/app/web/templates/
cp -r $(CLIENTBUILD)/static $(SERVERBUILD)/app/web/
cp $(CLIENTBUILD)/favicon.png $(SERVERBUILD)/app/web/static/img
cp $(CLIENTBUILD)/service-worker.js $(SERVERBUILD)/app/web/static/js/
$(call copy_client_assets,$(CLIENTBUILD),$(SERVERBUILD))
cp MANIFEST.in README.md setup.cfg setup.py $(BUILDDIR)
# If you are actively developing in the server folder use this, dirties the source tree
.PHONY: build-for-server-dev
build-for-server-dev: clean-server build-client
mkdir -p server/app/web/static/img
mkdir -p server/app/web/static/js
mkdir -p server/app/web/templates/
cp client/build/index.html server/app/web/templates/
cp -r client/build/static server/app/web/
cp client/build/favicon.png server/app/web/static/img
cp client/build/service-worker.js server/app/web/static/js/
build-for-server-dev: clean-server build-client copy-client-assets
.PHONY: copy-client-assets
copy-client-assets:
$(call copy_client_assets,client/build,server)
# TESTING
@@ -60,29 +54,56 @@ test: unit-test smoke-test
.PHONY: unit-test
unit-test: unit-test-server unit-test-client
unit-test-%:
cd $(*) && $(MAKE) unit-test
.PHONY: test-server
test-server: unit-test-server smoke-test
.PHONY: unit-test-client
unit-test-client:
cd client && $(MAKE) unit-test
.PHONY: unit-test-server
unit-test-server:
PYTHONWARNINGS=ignore:ResourceWarning coverage run \
--source=server \
--omit=.coverage,venv \
-m unittest discover \
--start-directory test/unit \
--verbose; test_result=$$?; \
exit $$test_result \
.PHONY: smoke-test
smoke-test:
cd client && $(MAKE) smoke-test
.PHONY: smoke-test-annotations
smoke-test-annotations:
cd client && $(MAKE) smoke-test-annotations
# FORMATTING CODE
.PHOHY: fmt
.PHONY: fmt
fmt: fmt-client fmt-py
.PHONY: fmt-client
fmt-client:
cd client && $(MAKE) fmt
.PHONY: fmt
fmt-py:
black .
.PHONY: lint
lint:
flake8 server
lint: lint-server lint-client
.PHONY: lint-server
lint-server: fmt-py
flake8 server --per-file-ignores='test/fixtures/dataset_config_outline.py:F821 test/fixtures/server_config_outline.py:F821 test/performance/scale_test_annotations.py:E501'
.PHONY: lint-client
lint-client:
cd client && $(MAKE) lint
# CREATING DISTRIBUTION RELEASE
.PHONY: pydist
@@ -90,48 +111,70 @@ pydist: build
cd $(BUILDDIR); python setup.py sdist -d ../dist
@echo "done"
# RELEASE HELPERS
# create new version to commit to master
.PHONY: release-stage-1
release-stage-1: dev-env bump clean-lite gen-package-lock
# Set PART=[major, minor, patch] as param to make bump.
# This will create a release candidate. (i.e. 0.16.1 -> 0.16.2-rc.0 for a patch bump)
.PHONY: bump-version
bump-version:
bumpversion --config-file .bumpversion.cfg $(PART)
# Create new version to commit to main
.PHONY: create-release-candidate
create-release-candidate: bump-version clean-lite gen-package-lock
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
# build dist and release to dev pypi
.PHONY: release-stage-2
release-stage-2: dev-env pydist twine
# Bump the release candidate version if needed (i.e. the previous release candidate had errors).
.PHONY: recreate-release-candidate
recreate-release-candidate: bump-release-candidate clean-lite gen-package-lock
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
# Build dist and release to Test PyPI
.PHONY: release-candidate-to-test-pypi
release-candidate-to-test-pypi: pydist twine
@echo "Dist built and uploaded to test.pypi.org"
@echo "Test the install:"
@echo " make install-release-test"
@echo "Then upload to Pypi prod:"
@echo " make twine-prod"
.PHONY: release-stage-final
release-stage-final: twine-prod
# Build final dist (gets rid of the rc tag) and release final candidate to TestPyPI
.PHONY: release-final-to-test-pypi
release-final-to-test-pypi: bump-release clean-lite gen-package-lock pydist twine
@echo "Final release dist built and uploaded to test.pypi.org"
@echo "Test the install:"
@echo " make install-release-test"
.PHONY: release-final
release-final: twine-prod
@echo "Release uploaded to pypi.org"
# DANGER: releases directly to prod
# use this if you accidently burned a test release version number,
# use this if you accidentally burned a test release version number,
.PHONY: release-directly-to-prod
release-directly-to-prod: dev-env pydist twine-prod
release-directly-to-prod: pydist twine-prod
@echo "Dist built and uploaded to pypi.org"
@echo "Test the install:"
@echo " make install-release"
.PHONY: dev-env
dev-env:
dev-env: dev-env-client dev-env-server
.PHONY: dev-env-client
dev-env-client:
cd client && $(MAKE) ci
.PHONY: dev-env-server
dev-env-server:
pip install -r server/requirements-dev.txt
.PHONY: gui-env
gui-env: dev-env
pip install -r server/requirements-gui.txt
# Increments the release candidate version (i.e. 0.16.2-rc.1 -> 0.16.2-rc.2)
.PHONY: bump-release-candidate
bump-release-candidate:
bumpversion --config-file .bumpversion.cfg prerelversion --allow-dirty
# give PART=[major, minor, part] as param to make bump
.PHONY: bump
bump:
bumpversion --config-file .bumpversion.cfg $(PART)
# Finalizes the release candidate by removing the release candidate tag (i.e. 0.16.2-rc.2 -> 0.16.2).
.PHONY: bump-release
bump-release:
bumpversion --config-file .bumpversion.cfg prerel --allow-dirty
.PHONY: twine
twine:
@@ -149,13 +192,6 @@ gen-package-lock:
# INSTALL
# setup.py sucks when you have your library in a separate folder, adding these in to help setup envs
# install from build directory
.PHONY: install
install: uninstall
cd $(BUILDDIR); pip install -e .
# install from source tree for development
.PHONY: install-dev
install-dev: uninstall
@@ -164,7 +200,7 @@ install-dev: uninstall
# install from test.pypi to test your release
.PHONY: install-release-test
install-release-test: uninstall
pip install --no-cache-dir --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene
pip install --no-cache-dir --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene==$(VERSION)
@echo "Installed cellxgene from test.pypi.org, now run and smoke test"
# install from pypi to test your release
@@ -182,30 +218,3 @@ install-dist: uninstall
uninstall:
pip uninstall -y cellxgene || :
# GUI
.PHONY: build-assets
build-assets:
pyside2-rcc server/gui/cellxgene.qrc -o server/gui/cellxgene_rc.py
.PHONY: gui-spec-osx
gui-spec-osx: clean-lite gui-env
pip install -e .[gui]
pyi-makespec -D -w --additional-hooks-dir server/gui/ -n cellxgene --add-binary='/System/Library/Frameworks/Tk.framework/Tk':'tk' --add-binary='/System/Library/Frameworks/Tcl.framework/Tcl':'tcl' --add-data server/app/web/templates/:server/app/web/templates/ --add-data server/app/web/static/:server/app/web/static/ --icon server/gui/images/cxg_icons.icns server/gui/main.py
mv cellxgene.spec cellxgene-osx.spec
.PHONY: gui-spec-windows
gui-spec-windows: clean-lite dev-env
pip install -e .[gui]
pyi-makespec -D -w --additional-hooks-dir server/gui/ -n cellxgene --add-data server/app/web/templates;server/app/web/templates --add-data server/app/web/static;server/app/web/static --icon server/gui/images/icon.ico server/gui/main.py
mv cellxgene.spec cellxgene-windows.spec
.PHONY: gui-build-osx
gui-build-osx: clean-lite
pyinstaller --clean cellxgene-osx.spec
.PHONY: gui-build-windows
gui-build-windows: clean-lite
pyinstaller --clean cellxgene-windows.spec
+11
View File
@@ -0,0 +1,11 @@
#### Reviewers
**Functional:**
**Readability:**
---
## Changes
- add
- remove
- modify
+20
View File
@@ -0,0 +1,20 @@
# Cellxgene Visium Beta
## How it works
1. Launch `cellxgene` as normal.
1. If the loaded dataset has spatial information available, the image data will be loaded on startup.
1. On the toolbar, next to the Zoom icon, a `Toggle image` button will now appear. Click on it and the image will be added as an underlay.
1. You can now use any `cellxgene` functionality and the image will still be present. If you pan and zoom, the image will also be panned and zoomed.
1. If you want to hide the image, you can click on `Toggle image` again
In order for the image to be displayed with the correct size and alignment, the H5AD needs to have a few requirements. See the following section to learn more.
## h5ad requirements
1. The spatial embedding layer should be contained in `obsm` and be named `X_spatial`. Other layers can exist, but only this one will have the spatial feature enabled.
2. A `spatial` dict needs to be defined in the `uns` dictionary.
3. Inside the `spatial` dict, an `images` dict must be defined.
4. The `images` dict must contain a `hires` key, which should reference an image encoded as an RGB matrix (i.e., a three-dimensional matrix of size `height x width x 3` where the final dimension has the RGB values for each pixel)
5. The `images` dict must contain a `scalefactors` dict. This should in turn contain a `tissue_hires_scalef` key, which should reference a floating point number.
Moreover, in order to have the image correctly aligned with the dots, the following must be true:
1. `tissue_hires_scalef` should represent the ratio between the embedding layer `X_spatial` and the image matrix. In particular, if you multiply `X_spatial` by `tissue_hires_scalef`, you should obtain an array of points that ovelap the tissue image if you plot them in a plane.
+49 -37
View File
@@ -3,40 +3,57 @@
_an interactive explorer for single-cell transcriptomics data_
[![DOI](https://zenodo.org/badge/105615409.svg)](https://zenodo.org/badge/latestdoi/105615409) [![PyPI](https://img.shields.io/pypi/v/cellxgene)](https://pypi.org/project/cellxgene/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/cellxgene)](https://pypistats.org/packages/cellxgene) [![GitHub last commit](https://img.shields.io/github/last-commit/chanzuckerberg/cellxgene)](https://github.com/chanzuckerberg/cellxgene/pulse)
[![Push Tests](https://github.com/chanzuckerberg/cellxgene/workflows/Push%20Tests/badge.svg)](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Push+Tests%22)
[![Compatibility Tests](https://github.com/chanzuckerberg/cellxgene/workflows/Compatibility%20Tests/badge.svg)](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Compatibility+Tests%22)
![Code Coverage](https://codecov.io/gh/chanzuckerberg/cellxgene/branch/main/graph/badge.svg)
cellxgene (pronounced "cell-by-gene") 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.
cellxgene Desktop (pronounced "cell-by-gene") is an interactive data explorer for single-cell 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.
Whether you need to visualize one thousand cells or one million, cellxgene helps you gain insight into your single-cell data.
Whether you need to visualize one thousand cells or one million, cellxgene Desktop helps you gain insight into your single-cell data.
<img src="https://github.com/chanzuckerberg/cellxgene/raw/master/docs/images/crossfilter.gif" width="350" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/raw/master/docs/images/category-breakdown.gif" width="350" height="200" hspace="30">
<img src="https://github.com/chanzuckerberg/cellxgene/raw/main/docs/images/crossfilter.gif" width="350" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/raw/main/docs/images/category-breakdown.gif" width="350" height="200" hspace="30">
# Getting started
### The comprehensive guide to cellxgene
[The cellxgene documentation is your one-stop-shop for information about cellxgene](https://chanzuckerberg.github.io/cellxgene/)! You may be particularly interested in:
* Seeing [what cellxgene can do](https://chanzuckerberg.github.io/cellxgene/posts/gallery)
* Learning more about cellxgene [installation](https://chanzuckerberg.github.io/cellxgene/posts/install) and [usage](https://chanzuckerberg.github.io/cellxgene/posts/launch)
* [Preparing your own data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) for use in cellxgene
* Checking out [our roadmap](https://chanzuckerberg.github.io/cellxgene/posts/roadmap) for future development
* [Contributing](https://chanzuckerberg.github.io/cellxgene/posts/contribute) to cellxgene
### The comprehensive guide to cellxgene Desktop
[The cellxgene documentation is your one-stop-shop for information about cellxgene Desktop](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/README.md)! You may be particularly interested in:
- Seeing [what cellxgene Desktop can do](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/explore-data/explorer-tutorials.md)
- Learning more about cellxgene [installation](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/install.md) and [usage](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/quick-start.md#quick-start-1)
- [Preparing your own data](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/data-reqs.md) for use in cellxgene Desktop
- Checking out [our roadmap](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/roadmap.md) for future development
- [Contributing](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/contribute.md) to cellxgene Desktop
### Quick start
To install cellxgene you need Python 3.6+. We recommend [installing cellxgene into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/posts/install)
To install cellxgene Desktop you need Python 3.6+. We recommend [installing cellxgene Desktop into a conda or virtual environment.](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/install.md)
Install the package.
``` bash
```bash
pip install cellxgene
```
Launch cellxgene with an example [anndata](https://anndata.readthedocs.io/en/latest/) file
Launch cellxgene Desktop with an example [anndata](https://anndata.readthedocs.io/en/latest/) file
``` bash
```bash
cellxgene launch https://cellxgene-example-data.czi.technology/pbmc3k.h5ad
```
To explore more datasets already formatted for cellxgene, check out the [Demo data](https://chanzuckerberg.github.io/cellxgene/posts/demo-data) or
see [Preparing your data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) to learn more about formatting your own
data for cellxgene.
To explore more datasets already formatted for cellxgene Desktop, check out the [Demo data](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/quick-start.md#example-datasets) or
see [Preparing your data](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/data-reqs.md) to learn more about formatting your own
data for cellxgene Desktop.
### Supported browsers
cellxgene Desktop currently supports the following browsers:
- Google Chrome 61+
- Edge 15+
- Firefox 60+
Please [file an issue](https://github.com/chanzuckerberg/cellxgene/issues/new/choose) if you would like us to add support for an unsupported browser.
### Finding help
@@ -45,39 +62,34 @@ For questions, suggestions, or accolades, [join the `#cellxgene-users` channel o
For any errors, [report bugs on Github](https://github.com/chanzuckerberg/cellxgene/issues).
# Developing with cellxgene
# Developing with cellxgene Desktop
### Contributing
We warmly welcome contributions from the community! Please see our [contributing guide](https://chanzuckerberg.github.io/cellxgene/posts/contribute) and don't hesitate to open an issue or send a pull request to improve cellxgene.
We warmly welcome contributions from the community! Please see our [contributing guide](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/contribute.md) and don't hesitate to open an issue or send a pull request to improve cellxgene Desktop. Please see the [dev_docs](https://github.com/chanzuckerberg/cellxgene/tree/main/dev_docs) for pull request suggestions, unit test details, local documentation preview, and other development specifics.
This project adheres to the Contributor Covenant [code of conduct](https://github.com/chanzuckerberg/.github/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to opensource@chanzuckerberg.com.
### 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).
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. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
Before extending cellxgene, we encourage you to reach out to us with ideas or questions. It might be possible that an
extension could be directly contributed, which would make it available for a wider audience, or that it's on our
[roadmap](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/roadmap.md) and under active development.
See the [cellxgene extensions](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/community-extensions.md) section of our documentation for examples of community use and cellxgene extensions.
### Security
If you believe you have found a security issue, we would appreciate notification. Please send email to <security@chanzuckerberg.com>.
# About
### Core team
# Inspiration
The current core team:
- Colin Megill, frontend & product design
- Bruce Martin, software engineer
- Sidney Bell, computational biologist
- Lia Prins, designer
- Severiano Badajoz, software engineer
We would also like to gratefully acknowledge contributions from past core team members:
- Charlotte Weaver, software engineer
### 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've been heavily inspired by several other related single-cell visualization projects, including the [UCSC Cell Browser](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [GenePattern](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.
-21
View File
@@ -1,21 +0,0 @@
{
"name": "cellxgene",
"description": "An interactive explorer for single-cell transcriptomics data",
"repository": "https://github.com/chanzuckerberg/cellxgene/",
"logo": "https://cellxgene-example-data.czi.technology/favicon.png",
"keywords": [
"scientific",
"visualization",
"scrna-seq",
"transcriptomics",
"dataviz"
],
"stack": "container",
"env": {
"DATASET": {
"description": "Link to dataset",
"value": "https://cellxgene-example-data.czi.technology/pbmc3k.h5ad",
"required": "true"
}
}
}
-41
View File
@@ -1,41 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(['server/gui/main.py'],
pathex=['/Users/charlotteweaver/Documents/Git/cellxgene'],
binaries=[('/System/Library/Frameworks/Tk.framework/Tk', 'tk'), ('/System/Library/Frameworks/Tcl.framework/Tcl', 'tcl')],
datas=[('server/app/web/templates/', 'server/app/web/templates/'), ('server/app/web/static/', 'server/app/web/static/')],
hiddenimports=['sklearn', 'sklearn.utils._cython_blas', 'sklearn.neighbors.typedefs', 'sklearn.neighbors.quad_tree', 'sklearn.tree', 'sklearn.tree._utils'],
hookspath=['server/gui/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='cellxgene',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False , icon='server/gui/images/cxg_icons.icns')
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='cellxgene')
app = BUNDLE(coll,
name='cellxgene.app',
icon='server/gui/images/cxg_icons.icns',
bundle_identifier=None)
-36
View File
@@ -1,36 +0,0 @@
# -*- mode: python -*-
block_cipher = None
a = Analysis(['server\\gui\\main.py'],
pathex=['C:\\Users\\Charlotte\\Documents\\git\\cellxgene'],
binaries=[],
datas=[('server/app/web/templates/', 'server/app/web/templates'), ('server/app/web/static/', 'server/app/web/static')],
hiddenimports=[],
hookspath=['server/gui/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='cellxgene',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False , icon='server\\gui\\images\\icon.ico')
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='cellxgene')
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd client
npx --no-install lint-staged --config "./configuration/lint-staged/lint-staged.config.js"
+46 -4
View File
@@ -1,20 +1,62 @@
include ../common.mk
ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../test/fixtures/pbmc3k-annotations.csv)
GENE_SETS := $(if $(GENE_SETS),$(GENE_SETS),../test/fixtures/pbmc3k-genesets.csv)
ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS))
GENE_SETS_FILENAME := $(shell basename $(GENE_SETS))
CXG_CONFIG := $(if $(CXG_CONFIG),$(CXG_CONFIG),./__tests__/e2e/test_config.yaml)
# Packaging
.PHONY: clean
clean:
rm -rf node_modules
rm -f __tests__/screenshots/*.png
.PHONY: ci
ci:
npm ci client
npm ci
.PHONY: install
install:
npm install client
npm install
.PHONY: build
WEBPACK_CONFIG ?= configuration/webpack/webpack.config.prod.js
build:
npm run build
npm run build $(WEBPACK_CONFIG)
# Development convenience methods
.PHONY: start-frontend
start-frontend:
node server/development.js
# start an instance of cellxgene and run the end-to-end tests
.PHONY: smoke-test
smoke-test:
start_server_and_test \
'CXG_OPTIONS="--config-file $(CXG_CONFIG)" $(MAKE) start-server' \
$(CXG_SERVER_PORT) \
'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e -- --verbose false'
# start an instance of cellxgene and run the end-to-end annotations tests
.PHONY: smoke-test-annotations
smoke-test-annotations:
$(eval TMP_DIR := $(shell mktemp -d /tmp/cellxgene_XXXXXX))
cp $(ANNOTATIONS) $(TMP_DIR)/ && \
cp $(GENE_SETS) $(TMP_DIR)/ && \
start_server_and_test \
'CXG_OPTIONS="--annotations-file $(TMP_DIR)/$(ANNOTATIONS_FILENAME) --gene-sets-file $(TMP_DIR)/$(GENE_SETS_FILENAME)" $(MAKE) start-server' \
$(CXG_SERVER_PORT) \
'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e-annotations -- --verbose false'
rm -rf $(TMP_DIR)
.PHONY: unit-test
unit-test:
node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e
# pass remaining commands through to npm run
%:
npm run $(*)
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`did launch page launched 1`] = `"<span style=\\"max-width: 155px; display: flex; overflow: hidden; justify-content: flex-start; width: 100%; padding: 0px;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">pbm</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">c3k</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">c3k</span></span></span>"`;
exports[`metadata loads categories and values from dataset appear 1`] = `"<div style=\\"display: flex; justify-content: space-between; align-items: baseline;\\"><div style=\\"display: flex; justify-content: flex-start; align-items: flex-start;\\"><label class=\\"bp3-control bp3-checkbox\\" for=\\"category-select-louvain\\"><input id=\\"category-select-louvain\\" data-testclass=\\"category-select\\" data-testid=\\"louvain:category-select\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span role=\\"menuitem\\" tabindex=\\"0\\" data-testclass=\\"category-expand\\" data-testid=\\"louvain:category-expand\\" style=\\"cursor: pointer;\\"><span aria-haspopup=\\"true\\" class=\\"bp3-popover2-target\\"><span data-testid=\\"louvain:category-label\\" tabindex=\\"-1\\" aria-label=\\"louvain\\" class=\\"\\" style=\\"max-width: 265px;\\"><span style=\\"max-width: 265px; display: flex; overflow: hidden; justify-content: flex-start; width: 100%; padding: 0px;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">lou</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">vain</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">vain</span></span></span></span></span><svg stroke=\\"currentColor\\" fill=\\"currentColor\\" stroke-width=\\"0\\" viewBox=\\"0 0 320 512\\" data-testclass=\\"category-expand-is-not-expanded\\" height=\\"1em\\" width=\\"1em\\" xmlns=\\"http://www.w3.org/2000/svg\\" style=\\"font-size: 10px; margin-left: 5px;\\"><path d=\\"M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z\\"></path></svg></span></div><div><span class=\\"bp3-popover-wrapper\\"><span aria-haspopup=\\"true\\" class=\\"bp3-popover-target\\"><a role=\\"button\\" data-testclass=\\"colorby\\" data-testid=\\"colorby-louvain\\" class=\\"bp3-button\\" tabindex=\\"0\\"><span icon=\\"tint\\" class=\\"bp3-icon bp3-icon-tint\\"><svg data-icon=\\"tint\\" width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\"><desc>tint</desc><path d=\\"M7.88 1s-4.9 6.28-4.9 8.9c.01 2.82 2.34 5.1 4.99 5.1 2.65-.01 5.03-2.3 5.03-5.13C12.99 7.17 7.88 1 7.88 1z\\" fill-rule=\\"evenodd\\"></path></svg></span></a></span></span></div></div><div style=\\"margin-left: 26px;\\"></div>"`;
File diff suppressed because one or more lines are too long
+445
View File
@@ -0,0 +1,445 @@
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
import { strict as assert } from "assert";
import {
clearInputAndTypeInto,
clickOn,
getAllByClass,
getOneElementInnerText,
typeInto,
waitByID,
waitByClass,
waitForAllByIds,
clickOnUntil,
getTestClass,
getTestId,
isElementPresent,
} from "./puppeteerUtils";
export async function drag(testId, start, end, lasso = false) {
const layout = await 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 page.mouse.move(x1, y1);
await page.mouse.down();
if (lasso) {
await page.mouse.move(x2, y1);
await page.mouse.move(x2, y2);
await page.mouse.move(x1, y2);
await page.mouse.move(x1, y1);
} else {
await page.mouse.move(x2, y2);
}
await page.mouse.up();
}
export async function clickOnCoordinate(testId, coord) {
const layout = await expect(page).toMatchElement(getTestId(testId));
const elBox = await layout.boxModel();
if (!elBox) {
throw Error("Layout's boxModel is not available!");
}
const x = elBox.content[0].x + coord.x;
const y = elBox.content[0].y + coord.y;
await page.mouse.click(x, y);
}
export async function getAllHistograms(testclass, testIds) {
const histTestIds = testIds.map((tid) => `histogram-${tid}`);
// these load asynchronously, so we need to wait for each histogram individually,
// and they may be quite slow in some cases.
await waitForAllByIds(histTestIds, { timeout: 4 * 60 * 1000 });
const allHistograms = await getAllByClass(testclass);
const testIDs = await Promise.all(
allHistograms.map((hist) =>
page.evaluate((elem) => elem.dataset.testid, hist)
)
);
return testIDs.map((id) => id.replace(/^histogram-/, ""));
}
export async function getAllCategoriesAndCounts(category) {
// these load asynchronously, so we have to wait for the specific category.
await waitByID(`category-${category}`);
return page.$$eval(
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
(rows) =>
Object.fromEntries(
rows.map((row) => {
const cat = row
.querySelector("[data-testclass='categorical-value']")
.getAttribute("aria-label");
const count = row.querySelector(
"[data-testclass='categorical-value-count']"
).innerText;
return [cat, count];
})
)
);
}
export async function getCellSetCount(num) {
await clickOn(`cellset-button-${num}`);
return getOneElementInnerText(`[data-testid='cellset-count-${num}']`);
}
export async function resetCategory(category) {
const checkboxId = `${category}:category-select`;
await waitByID(checkboxId);
const checkedPseudoclass = await page.$eval(
`[data-testid='${checkboxId}']`,
(el) => el.matches(":checked")
);
if (!checkedPseudoclass) await clickOn(checkboxId);
const categoryRow = await waitByID(`${category}:category-expand`);
const isExpanded = await categoryRow.$(
"[data-testclass='category-expand-is-expanded']"
);
if (isExpanded) await clickOn(`${category}:category-expand`);
}
export async function calcCoordinate(testId, xAsPercent, yAsPercent) {
const el = await waitByID(testId);
const size = await el.boxModel();
return {
x: Math.floor(size.width * xAsPercent),
y: Math.floor(size.height * yAsPercent),
};
}
export async function calcDragCoordinates(testId, coordinateAsPercent) {
return {
start: await calcCoordinate(
testId,
coordinateAsPercent.x1,
coordinateAsPercent.y1
),
end: await calcCoordinate(
testId,
coordinateAsPercent.x2,
coordinateAsPercent.y2
),
};
}
export async function selectCategory(category, values, reset = true) {
if (reset) await resetCategory(category);
await clickOn(`${category}:category-expand`);
await clickOn(`${category}:category-select`);
for (const value of values) {
await clickOn(`categorical-value-select-${category}-${value}`);
}
}
export async function expandCategory(category) {
const expand = await waitByID(`${category}:category-expand`);
const notExpanded = await expand.$(
"[data-testclass='category-expand-is-not-expanded']"
);
if (notExpanded) await clickOn(`${category}:category-expand`);
}
export async function clip(min = 0, max = 100) {
await clickOn("visualization-settings");
await clearInputAndTypeInto("clip-min-input", min);
await clearInputAndTypeInto("clip-max-input", max);
await clickOn("clip-commit");
}
export async function createCategory(categoryName) {
await clickOnUntil("open-annotation-dialog", async () => {
await expect(page).toMatchElement(getTestId("new-category-name"));
});
await typeInto("new-category-name", categoryName);
await clickOn("submit-category");
}
/*
GENESET
*/
export async function colorByGeneset(genesetName) {
await clickOn(`${genesetName}:colorby-entire-geneset`);
}
export async function colorByGene(gene) {
await clickOn(`colorby-${gene}`);
}
export async function assertColorLegendLabel(label) {
const handle = await waitByID("continuous_legend_color_by_label");
const result = await handle.evaluate((node) =>
node.getAttribute("aria-label")
);
return expect(result).toBe(label);
}
export async function expandGeneset(genesetName) {
const expand = await waitByID(`${genesetName}:geneset-expand`);
const notExpanded = await expand.$(
"[data-testclass='geneset-expand-is-not-expanded']"
);
if (notExpanded) await clickOn(`${genesetName}:geneset-expand`);
}
export async function createGeneset(genesetName) {
await clickOnUntil("open-create-geneset-dialog", async () => {
await expect(page).toMatchElement(getTestId("create-geneset-input"));
});
await typeInto("create-geneset-input", genesetName);
await clickOn("submit-geneset");
await waitByClass("autosave-complete");
}
export async function editGenesetName(genesetName, editText) {
const editButton = `${genesetName}:edit-genesetName-mode`;
const submitButton = `${genesetName}:submit-geneset`;
await clickOnUntil(`${genesetName}:see-actions`, async () => {
await expect(page).toMatchElement(getTestId(editButton));
});
await clickOn(editButton);
await typeInto("rename-geneset-modal", editText);
await clickOn(submitButton);
}
export async function deleteGeneset(genesetName) {
const targetId = `${genesetName}:delete-geneset`;
await clickOnUntil(`${genesetName}:see-actions`, async () => {
await expect(page).toMatchElement(getTestId(targetId));
});
await clickOn(targetId);
await assertGenesetDoesNotExist(genesetName);
await waitByClass("autosave-complete");
}
export async function assertGenesetDoesNotExist(genesetName) {
const result = await isElementPresent(
getTestId(`${genesetName}:geneset-name`)
);
await expect(result).toBe(false);
}
export async function assertGenesetExists(genesetName) {
const handle = await waitByID(`${genesetName}:geneset-name`);
const result = await handle.evaluate((node) =>
node.getAttribute("aria-label")
);
return expect(result).toBe(genesetName);
}
/*
GENE
*/
export async function addGeneToSet(genesetName, geneToAddToSet) {
const submitButton = `${genesetName}:submit-gene`;
await clickOn(`${genesetName}:add-new-gene-to-geneset`);
await typeInto("add-genes", geneToAddToSet);
await clickOn(submitButton);
}
export async function removeGene(geneSymbol) {
const targetId = `delete-from-geneset:${geneSymbol}`;
await clickOn(targetId);
await waitByClass("autosave-complete");
}
export async function assertGeneExistsInGeneset(geneSymbol) {
const handle = await waitByID(`${geneSymbol}:gene-label`);
const result = await handle.evaluate((node) =>
node.getAttribute("aria-label")
);
return expect(result).toBe(geneSymbol);
}
export async function assertGeneDoesNotExist(geneSymbol) {
const result = await isElementPresent(getTestId(`${geneSymbol}:gene-label`));
await expect(result).toBe(false);
}
export async function expandGene(geneSymbol) {
await clickOn(`maximize-${geneSymbol}`);
}
/*
CATEGORY
*/
export async function duplicateCategory(categoryName) {
await clickOn("open-annotation-dialog");
await typeInto("new-category-name", categoryName);
const dropdownOptionClass = "duplicate-category-dropdown-option";
await clickOnUntil("duplicate-category-dropdown", async () => {
await expect(page).toMatchElement(getTestClass(dropdownOptionClass));
});
const option = await expect(page).toMatchElement(
getTestClass(dropdownOptionClass)
);
await option.click();
await clickOnUntil("submit-category", async () => {
await expect(page).toMatchElement(
getTestId(`${categoryName}:category-expand`)
);
});
await waitByClass("autosave-complete");
}
export async function renameCategory(oldCategoryName, newCategoryName) {
await clickOn(`${oldCategoryName}:see-actions`);
await clickOn(`${oldCategoryName}:edit-category-mode`);
await clearInputAndTypeInto(
`${oldCategoryName}:edit-category-name-text`,
newCategoryName
);
await clickOn(`${oldCategoryName}:submit-category-edit`);
}
export async function deleteCategory(categoryName) {
const targetId = `${categoryName}:delete-category`;
await clickOnUntil(`${categoryName}:see-actions`, async () => {
await expect(page).toMatchElement(getTestId(targetId));
});
await clickOn(targetId);
await assertCategoryDoesNotExist();
}
export async function createLabel(categoryName, labelName) {
/**
* (thuang): This explicit wait is needed, since currently showing
* the modal again quickly after the previous action dismissing the
* modal will persist the input value from the previous action.
*
* To reproduce:
* 1. Click on the plus sign to show the modal to add a new label to the category
* 2. Type `123` in the input box
* 3. Hover over your mouse over the plus sign and double click to quickly dismiss and
* invoke the modal again
* 4. You will see `123` is persisted in the input box
* 5. Expected behavior is to get an empty input box
*/
await page.waitForTimeout(500);
await clickOn(`${categoryName}:see-actions`);
await clickOn(`${categoryName}:add-new-label-to-category`);
await typeInto(`${categoryName}:new-label-name`, labelName);
await clickOn(`${categoryName}:submit-label`);
}
export async function deleteLabel(categoryName, labelName) {
await expandCategory(categoryName);
await clickOn(`${categoryName}:${labelName}:see-actions`);
await clickOn(`${categoryName}:${labelName}:delete-label`);
}
export async function renameLabel(categoryName, oldLabelName, newLabelName) {
await expandCategory(categoryName);
await clickOn(`${categoryName}:${oldLabelName}:see-actions`);
await clickOn(`${categoryName}:${oldLabelName}:edit-label`);
await clearInputAndTypeInto(
`${categoryName}:${oldLabelName}:edit-label-name`,
newLabelName
);
await clickOn(`${categoryName}:${oldLabelName}:submit-label-edit`);
}
export async function addGeneToSearch(geneName) {
await typeInto("gene-search", geneName);
await page.keyboard.press("Enter");
await page.waitForSelector(`[data-testid='histogram-${geneName}']`);
}
export async function subset(coordinatesAsPercent) {
// In order to deselect the selection after the subset, make sure we have some clear part
// of the scatterplot we can click on
assert(coordinatesAsPercent.x2 < 0.99 || coordinatesAsPercent.y2 < 0.99);
const lassoSelection = await calcDragCoordinates(
"layout-graph",
coordinatesAsPercent
);
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
await clickOn("subset-button");
const clearCoordinate = await calcCoordinate("layout-graph", 0.5, 0.99);
await clickOnCoordinate("layout-graph", clearCoordinate);
}
export async function setSellSet(cellSet, cellSetNum) {
const selections = cellSet.filter((sel) => sel.kind === "categorical");
for (const selection of selections) {
await selectCategory(selection.metadata, selection.values, true);
}
await getCellSetCount(cellSetNum);
}
export async function runDiffExp(cellSet1, cellSet2) {
await setSellSet(cellSet1, 1);
await setSellSet(cellSet2, 2);
await clickOn("diffexp-button");
}
export async function bulkAddGenes(geneNames) {
await clickOn("section-bulk-add");
await typeInto("input-bulk-add", geneNames.join(","));
await page.keyboard.press("Enter");
}
export async function assertCategoryDoesNotExist(categoryName) {
const result = await isElementPresent(
getTestId(`${categoryName}:category-label`)
);
await expect(result).toBe(false);
}
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
+9 -5
View File
@@ -1,6 +1,10 @@
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";
import * as ENV_DEFAULT from "../../../environment.default.json";
export const jestEnv = process.env.JEST_ENV || ENV_DEFAULT.JEST_ENV;
export const appUrlBase =
process.env.CXG_URL_BASE || `http://localhost:${ENV_DEFAULT.CXG_CLIENT_PORT}`;
export const DATASET = "pbmc3k";
export const isDev = jestEnv === ENV_DEFAULT.DEV;
export const isDebug = jestEnv === ENV_DEFAULT.DEBUG;
export const TEST_EMAIL = "user@example.com";
export const TEST_PASSWORD = process.env.TEST_ACCOUNT_PASS ?? "";
+51 -32
View File
@@ -4,7 +4,7 @@ export const datasets = {
dataframe: {
nObs: "2638",
nVar: "1838",
type: "float32"
type: "float32",
},
categorical: {
louvain: {
@@ -15,47 +15,47 @@ export const datasets = {
"Dendritic cells": "37",
"FCGR3A+ Monocytes": "150",
Megakaryocytes: "15",
"NK cells": "154"
}
"NK cells": "154",
},
},
continuous: {
n_genes: "int32",
percent_mito: "float32",
n_counts: "float32"
n_counts: "float32",
},
cellsets: {
lasso: [
{
"coordinates-as-percent": { x1: 0.1, y1: 0.25, x2: 0.7, y2: 0.75 },
count: "1181"
}
count: "1131",
},
],
categorical: [
{
metadata: "louvain",
values: ["B cells", "Megakaryocytes"],
count: "357"
}
count: "357",
},
],
continuous: [
{
metadata: "n_genes",
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
count: "1552"
}
]
count: "1537",
},
],
},
diffexp: {
cellset1: [
{ kind: "categorical", metadata: "louvain", values: ["B cells"] }
{ kind: "categorical", metadata: "louvain", values: ["B cells"] },
],
cellset2: [
{
kind: "categorical",
metadata: "louvain",
values: ["CD4 T cells", "NK cells"]
}
values: ["CD4 T cells", "NK cells"],
},
],
"gene-results": [
"HLA-DRB1",
@@ -67,21 +67,21 @@ export const datasets = {
"HLA-DQB1",
"MS4A1",
"IL32",
"CD37"
]
"CD37",
],
},
genes: {
bulkadd: ["S100A8", "FCGR3A", "LGALS2", "GSTP1"],
search: "ACD"
search: "ACD",
},
subset: {
cellset1: [
{
kind: "categorical",
metadata: "louvain",
values: ["B cells", "Megakaryocytes"]
}
values: ["B cells", "Megakaryocytes"],
},
],
count: "357",
categorical: {
@@ -93,27 +93,46 @@ export const datasets = {
"Dendritic cells": "0",
"FCGR3A+ Monocytes": "0",
Megakaryocytes: "15",
"NK cells": "0"
}
"NK cells": "0",
},
},
lasso: {
"coordinates-as-percent": { x1: 0.25, y1: 0.05, x2: 0.75, y2: 0.55 },
count: "329"
}
count: "331",
},
},
scatter: {
genes: { x: "S100A8", y: "FCGR3A" }
genes: { x: "S100A8", y: "FCGR3A" },
},
pan: {
"coordinates-as-percent": { x1: 0.75, y1: 0.75, x2: 0.35, y2: 0.35 }
"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"
}
}
count: "38",
},
},
},
categoryLabel: {
lasso: {
"coordinates-as-percent": { x1: 0.05, y1: 0.3, x2: 0.5, y2: 0.5 },
},
newCount: {
bySubsetConfig: {
false: "668",
true: "659",
},
},
},
annotationsFromFile: {
count: {
bySubsetConfig: {
false: "1161",
true: "852",
},
},
},
clip: {
min: "30",
@@ -121,8 +140,8 @@ export const datasets = {
metadata: "n_genes",
gene: "S100A8",
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
count: "392",
"gene-cell-count": "421"
}
}
count: "386",
"gene-cell-count": "416",
},
},
};
+105
View File
@@ -0,0 +1,105 @@
export const diffexpPop1Genes = [
"CD79A",
"HLA-DRB1",
"HLA-DQA1",
"HLA-DPB1",
"HLA-DQB1",
"HLA-DPA1",
"MS4A1",
"LTB",
"CD79B",
"CD37",
"HLA-DMA",
"TCL1A",
"LINC00926",
"HLA-DMB",
"HVCN1",
"EAF2",
"FCRLA",
"IRF8",
"PKIG",
"P2RX5",
"HLA-DOB",
"SPIB",
"BLNK",
"SWAP70",
"PNOC",
"CD19",
"SMIM14",
"CD72",
"KIAA0125",
"IGLL5",
"ARHGAP24",
"COTL1",
"C16orf74",
"BTK",
"SNX29P2",
"ADAM28",
"FCGR2B",
"PLD4",
"PPP1R14A",
"MZB1",
"KIAA0040",
"PHACTR1",
"FCRL2",
"RIC3",
"P2RY10",
"SCPEP1",
"DRAM2",
"RP5-887A10.1",
"CD82",
"GPX1",
];
export const diffexpPop2Genes = [
"NKG7",
"GZMB",
"CTSW",
"PRF1",
"GNLY",
"GZMA",
"CST7",
"FGFBP2",
"SRGN",
"CD247",
"FCGR3A",
"TYROBP",
"FCER1G",
"ID2",
"SPON2",
"CCL4",
"CCL5",
"GZMH",
"GIMAP7",
"CLIC3",
"HOPX",
"XCL2",
"LGALS1",
"IGFBP7",
"AKR1C3",
"IL32",
"EFHD2",
"PRSS23",
"TTC38",
"ZAP70",
"S1PR5",
"SAMD3",
"GIMAP4",
"CCL3",
"ABI3",
"XCL1",
"S100A6",
"UBB",
"GPR56",
"PDIA3",
"S100A11",
"APOBEC3G",
"HAVCR2",
"PLEKHF1",
"LITAF",
"ARPC5L",
"PTGDR",
"PRMT2",
"GSTP1",
"FCRL6",
];
+239 -231
View File
@@ -1,79 +1,63 @@
/*
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
/**
* 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";
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
import { appUrlBase, DATASET } from "./config";
import { datasets } from "./data";
let browser, page, utils, cxgActions, spy;
const browserViewport = { width: 1280, height: 960 };
let data = datasets[DATASET];
import {
clickOn,
getAllByClass,
getElementCoordinates,
getOneElementInnerHTML,
getTestId,
goToPage,
waitByID,
} from "./puppeteerUtils";
if (DEBUG) jest.setTimeout(100000);
if (DEV) jest.setTimeout(10000);
import {
calcDragCoordinates,
clip,
drag,
getAllCategoriesAndCounts,
getCellSetCount,
selectCategory,
} from "./cellxgeneActions";
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();
}
});
const data = datasets[DATASET];
describe("did launch", () => {
test("page launched", async () => {
let el = await utils.getOneElementInnerHTML("[data-testid='header']");
expect(el).toBe(data.title);
await goToPage(appUrlBase);
const element = await getOneElementInnerHTML(getTestId("header"));
expect(element).toMatchSnapshot();
});
});
describe("metadata loads", () => {
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}"]`
await goToPage(appUrlBase);
for (const label of Object.keys(data.categorical)) {
const element = await getOneElementInnerHTML(
getTestId(`category-${label}`)
);
expect(categoryName).toMatch(label);
await utils.clickOn(`category-expand-${label}`);
const categories = await cxgActions.getAllCategoriesAndCounts(label);
expect(element).toMatchSnapshot();
await clickOn(`${label}:category-expand`);
const categories = await getAllCategoriesAndCounts(label);
expect(Object.keys(categories)).toMatchObject(
Object.keys(data.categorical[label])
);
expect(Object.values(categories)).toMatchObject(
Object.values(data.categorical[label])
);
@@ -81,263 +65,287 @@ describe("metadata loads", () => {
});
test("continuous data appears", async () => {
for (const label in data.continuous) {
await utils.waitByID(`histogram-${label}`);
await goToPage(appUrlBase);
for (const label of Object.keys(data.continuous)) {
await waitByID(`histogram-${label}`);
}
});
});
describe("cell selection", () => {
test("selects all cells cellset 1", async () => {
const cellCount = await cxgActions.cellSet(1);
await goToPage(appUrlBase);
const cellCount = await getCellSetCount(1);
expect(cellCount).toBe(data.dataframe.nObs);
});
test("selects all cells cellset 2", async () => {
const cellCount = await cxgActions.cellSet(2);
await goToPage(appUrlBase);
const cellCount = await getCellSetCount(2);
expect(cellCount).toBe(data.dataframe.nObs);
});
test("selects cells via lasso", async () => {
await goToPage(appUrlBase);
for (const cellset of data.cellsets.lasso) {
const cellset1 = await cxgActions.calcDragCoordinates(
const cellset1 = await calcDragCoordinates(
"layout-graph",
cellset["coordinates-as-percent"]
);
await cxgActions.drag("layout-graph", cellset1.start, cellset1.end, true);
const cellCount = await cxgActions.cellSet(1);
await drag("layout-graph", cellset1.start, cellset1.end, true);
const cellCount = await getCellSetCount(1);
expect(cellCount).toBe(cellset.count);
}
});
test("selects cells via categorical", async () => {
await goToPage(appUrlBase);
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}`
);
await clickOn(`${cellset.metadata}:category-expand`);
await clickOn(`${cellset.metadata}:category-select`);
for (const value of cellset.values) {
await clickOn(`categorical-value-select-${cellset.metadata}-${value}`);
}
const cellCount = await cxgActions.cellSet(1);
const cellCount = await getCellSetCount(1);
expect(cellCount).toBe(cellset.count);
}
});
test("selects cells via continuous", async () => {
await goToPage(appUrlBase);
for (const cellset of data.cellsets.continuous) {
const histBrushableAreaId = `histogram-${cellset.metadata}-plot-brushable-area`;
const coords = await cxgActions.calcDragCoordinates(
const coords = await calcDragCoordinates(
histBrushableAreaId,
cellset["coordinates-as-percent"]
);
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
await drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await getCellSetCount(1);
expect(cellCount).toBe(cellset.count);
}
});
});
describe("gene entry", () => {
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 allHistograms = await cxgActions.getAllHistograms(
"histogram-user-gene",
testGenes
);
expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
expect(allHistograms.length).toEqual(testGenes.length);
});
});
describe("diffexp", () => {
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 allHistograms = await cxgActions.getAllHistograms(
"histogram-diffexp",
data.diffexp["gene-results"]
);
expect(allHistograms).toEqual(
expect.arrayContaining(data.diffexp["gene-results"])
);
expect(allHistograms.length).toEqual(data.diffexp["gene-results"].length);
});
});
describe("subset/reset", () => {
describe("subset", () => {
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])
);
}
});
await goToPage(appUrlBase);
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 selectCategory(select.metadata, select.values, true);
}
}
await utils.clickOn("subset-button");
for (const label in data.subset.categorical) {
const categories = await cxgActions.getAllCategoriesAndCounts(label);
await clickOn("subset-button");
for (const label of Object.keys(data.subset.categorical)) {
const categories = await 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 () => {
await goToPage(appUrlBase);
for (const select of data.subset.cellset1) {
if (select.kind === "categorical") {
await cxgActions.selectCategory(select.metadata, select.values, true);
await selectCategory(select.metadata, select.values, true);
}
}
await utils.clickOn("subset-button");
const lassoSelection = await cxgActions.calcDragCoordinates(
await clickOn("subset-button");
const lassoSelection = await 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", () => {
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");
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
const cellCount = await getCellSetCount(1);
expect(cellCount).toBe(data.subset.lasso.count);
});
});
describe("clipping", () => {
test("clip continuous", async () => {
await cxgActions.clip(data.clip.min, data.clip.max);
const histBrushableAreaId = `histogram-${data.clip.metadata}-plot-brushable-area`;
const coords = await cxgActions.calcDragCoordinates(
histBrushableAreaId,
data.clip["coordinates-as-percent"]
);
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.clip.count);
});
await goToPage(appUrlBase);
test("clip gene", async () => {
await utils.typeInto("gene-search", data.clip.gene);
await page.keyboard.press("Enter");
await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`);
await cxgActions.clip(data.clip.min, data.clip.max);
const histBrushableAreaId = `histogram-${data.clip.gene}-plot-brushable-area`;
const coords = await cxgActions.calcDragCoordinates(
await clip(data.clip.min, data.clip.max);
const histBrushableAreaId = `histogram-${data.clip.metadata}-plot-brushable-area`;
const coords = await calcDragCoordinates(
histBrushableAreaId,
data.clip["coordinates-as-percent"]
);
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await cxgActions.cellSet(1);
expect(cellCount).toBe(data.clip["gene-cell-count"]);
await drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await getCellSetCount(1);
expect(cellCount).toBe(data.clip.count);
});
});
// interact with UI elements just that they do not break
describe("ui elements don't error", () => {
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}`);
}
});
await goToPage(appUrlBase);
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}`);
const allLabels = [
...Object.keys(data.categorical),
...Object.keys(data.continuous),
];
for (const label of allLabels) {
await clickOn(`colorby-${label}`);
}
});
test("pan and zoom", async () => {
await utils.clickOn("mode-pan-zoom");
const panCoords = await cxgActions.calcDragCoordinates(
await goToPage(appUrlBase);
await clickOn("mode-pan-zoom");
const panCoords = await 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);`);
await drag("layout-graph", panCoords.start, panCoords.end, false);
await page.evaluate("window.scrollBy(0, 1000);");
});
});
describe("centroid labels", () => {
test("labels are created", async () => {
await goToPage(appUrlBase);
const labels = Object.keys(data.categorical);
await clickOn(`colorby-${labels[0]}`);
await clickOn("centroid-label-toggle");
// Toggle colorby for each category and check to see if labels are generated
for (let i = 0, { length } = labels; i < length; i += 1) {
const label = labels[i];
// first label is already enabled
if (i !== 0) await clickOn(`colorby-${label}`);
const generatedLabels = await getAllByClass("centroid-label");
// Number of labels generated should be equal to size of the object
expect(generatedLabels).toHaveLength(
Object.keys(data.categorical[label]).length
);
}
});
});
describe("graph overlay", () => {
test("transform centroids correctly", async () => {
await goToPage(appUrlBase);
const category = Object.keys(data.categorical)[0];
await clickOn(`colorby-${category}`);
await clickOn("centroid-label-toggle");
await clickOn("mode-pan-zoom");
const panCoords = await calcDragCoordinates(
"layout-graph",
data.pan["coordinates-as-percent"]
);
const categoryValue = Object.keys(data.categorical[category])[0];
const initialCoordinates = await getElementCoordinates(
`${categoryValue}-centroid-label`
);
await drag("layout-graph", panCoords.start, panCoords.end, false);
const terminalCoordinates = await getElementCoordinates(
`${categoryValue}-centroid-label`
);
expect(terminalCoordinates[0] - initialCoordinates[0]).toBeCloseTo(
panCoords.end.x - panCoords.start.x
);
expect(terminalCoordinates[1] - initialCoordinates[1]).toBeCloseTo(
panCoords.end.y - panCoords.start.y
);
});
});
test("pan zoom mode resets lasso selection", async () => {
await goToPage(appUrlBase);
const panzoomLasso = data.features.panzoom.lasso;
const lassoSelection = await calcDragCoordinates(
"layout-graph",
panzoomLasso["coordinates-as-percent"]
);
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
await waitByID("lasso-element", { visible: true });
const initialCount = await getCellSetCount(1);
expect(initialCount).toBe(panzoomLasso.count);
await clickOn("mode-pan-zoom");
await clickOn("mode-lasso");
const modeSwitchCount = await getCellSetCount(1);
expect(modeSwitchCount).toBe(initialCount);
});
test("lasso moves after pan", async () => {
await goToPage(appUrlBase);
const panzoomLasso = data.features.panzoom.lasso;
const coordinatesAsPercent = panzoomLasso["coordinates-as-percent"];
const lassoSelection = await calcDragCoordinates(
"layout-graph",
coordinatesAsPercent
);
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
await waitByID("lasso-element", { visible: true });
const initialCount = await getCellSetCount(1);
expect(initialCount).toBe(panzoomLasso.count);
await clickOn("mode-pan-zoom");
const panCoords = await calcDragCoordinates(
"layout-graph",
coordinatesAsPercent
);
await drag("layout-graph", panCoords.start, panCoords.end, false);
await clickOn("mode-lasso");
const panCount = await getCellSetCount(2);
expect(panCount).toBe(initialCount);
});
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
+595
View File
@@ -0,0 +1,595 @@
/*
Tests included in this file are specific to annotation features
*/
import { appUrlBase, DATASET } from "./config";
import { datasets } from "./data";
import {
clickOn,
goToPage,
waitByClass,
waitByID,
getTestId,
getTestClass,
getAllByClass,
clickOnUntil,
getOneElementInnerHTML,
} from "./puppeteerUtils";
import {
assertCategoryDoesNotExist,
calcDragCoordinates,
createCategory,
createLabel,
deleteCategory,
deleteLabel,
drag,
expandCategory,
renameCategory,
renameLabel,
subset,
duplicateCategory,
createGeneset,
deleteGeneset,
assertGenesetExists,
assertGenesetDoesNotExist,
getCellSetCount,
expandGeneset,
editGenesetName,
addGeneToSet,
assertGeneExistsInGeneset,
removeGene,
assertGeneDoesNotExist,
expandGene,
colorByGeneset,
assertColorLegendLabel,
colorByGene,
} from "./cellxgeneActions";
const data = datasets[DATASET];
const perTestCategoryName = "TEST-CATEGORY";
const perTestLabelName = "TEST-LABEL";
// geneset CRUD
const genesetToDeleteName = "geneset_to_delete";
const preExistingGenesetName = "fifth_dataset";
const meanExpressionBrushGenesetName = "second_gene_set";
const meanExpressionBrushCellsSelected = "557";
const subsetMeanExpressionBrushCellsSelected = "452";
// initial text, the text we type in, the result
const editableGenesetName = "geneset_to_edit";
const editText = "_111";
const newGenesetName = "geneset_to_edit_111";
// add gene to set
const geneToAddToSet = "RER1";
const setToAddGeneTo = "fill_this_geneset";
// remove gene from set
const geneToRemove = "SIK1";
const setToRemoveFrom = "empty_this_geneset";
// brush a gene
const geneToBrushAndColorBy = "SIK1";
const brushThisGeneGeneset = "brush_this_gene";
const geneBrushedCellCount = "109";
const subsetGeneBrushedCellCount = "96";
const genesetDescriptionID =
"geneset-description-tooltip-fourth_gene_set: fourth description";
const genesetDescriptionString = "fourth_gene_set: fourth description";
const genesetToCheckForDescription = "fourth_gene_set";
async function setup(config) {
await goToPage(appUrlBase);
if (config.categoricalAnno) {
// setup the test fixtures
await createCategory(perTestCategoryName);
await createLabel(perTestCategoryName, perTestLabelName);
}
if (config.withSubset) {
await subset({ x1: 0.1, y1: 0.1, x2: 0.8, y2: 0.8 });
}
await waitByClass("autosave-complete");
}
describe.each([
{ withSubset: true, tag: "subset" },
{ withSubset: false, tag: "whole" },
])("geneSET crud operations and interactions", (config) => {
test("genesets load from csv", async () => {
await setup(config);
await assertGenesetExists(preExistingGenesetName);
});
test("brush on geneset mean", async () => {
await setup(config);
await expandGeneset(meanExpressionBrushGenesetName);
const histBrushableAreaId = `histogram-${meanExpressionBrushGenesetName}-plot-brushable-area`;
const coords = await calcDragCoordinates(histBrushableAreaId, {
x1: 0.25,
y1: 0.5,
x2: 0.55,
y2: 0.5,
});
await drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await getCellSetCount(1);
if (config.withSubset) {
expect(cellCount).toBe(subsetMeanExpressionBrushCellsSelected);
} else {
expect(cellCount).toBe(meanExpressionBrushCellsSelected);
}
});
test("color by mean expression", async () => {
await setup(config);
await colorByGeneset(meanExpressionBrushGenesetName);
await assertColorLegendLabel(meanExpressionBrushGenesetName);
});
test("diffexp", async () => {
if (config.withSubset) return;
await setup(config);
// set the two cell sets to b cells vs nk cells
await expandCategory(`louvain`);
await clickOn(`louvain:category-select`);
await clickOn(`categorical-value-select-louvain-B cells`);
await clickOn(`cellset-button-1`);
await clickOn(`categorical-value-select-louvain-B cells`);
await clickOn(`categorical-value-select-louvain-NK cells`);
await clickOn(`cellset-button-2`);
// run diffexp
await clickOn(`diffexp-button`);
await waitByClass("pop-1-geneset-expand");
await expect(page).toClick(getTestClass("pop-1-geneset-expand"));
await page.waitForFunction(
(selector) => !document.querySelector(selector),
{},
getTestClass("gene-loading-spinner")
);
let genesHTML = await getOneElementInnerHTML(
getTestClass("gene-set-genes")
);
expect(genesHTML).toMatchSnapshot();
await expect(page).toClick(getTestClass("pop-1-geneset-expand"));
await expect(page).toClick(getTestClass("pop-2-geneset-expand"));
await page.waitForFunction(
(selector) => !document.querySelector(selector),
{},
getTestClass("gene-loading-spinner")
);
genesHTML = await getOneElementInnerHTML(getTestClass("gene-set-genes"));
expect(genesHTML).toMatchSnapshot();
});
test("create a new geneset and undo/redo", async () => {
if (config.withSubset) return;
await setup(config);
const genesetName = `test-geneset-foo-123`;
await assertGenesetDoesNotExist(genesetName);
await createGeneset(genesetName);
/* note: as of June 2021, the aria label is in the truncate component which clones the element */
await assertGenesetExists(genesetName);
await clickOn("undo");
await assertGenesetDoesNotExist(genesetName);
await clickOn("redo");
await assertGenesetExists(genesetName);
});
test("edit geneset name and undo/redo", async () => {
await setup(config);
await editGenesetName(editableGenesetName, editText);
await assertGenesetExists(newGenesetName);
await clickOn("undo");
await assertGenesetExists(editableGenesetName);
await clickOn("redo");
await assertGenesetExists(newGenesetName);
});
test("delete a geneset and undo/redo", async () => {
if (config.withSubset) return;
await setup(config);
await deleteGeneset(genesetToDeleteName);
await clickOn("undo");
await assertGenesetExists(genesetToDeleteName);
await clickOn("redo");
await assertGenesetDoesNotExist(genesetToDeleteName);
});
test("geneset description", async () => {
if (config.withSubset) return;
await setup(config);
await clickOnUntil(
`${genesetToCheckForDescription}:geneset-expand`,
async () => {
expect(page).toMatchElement(getTestId(genesetDescriptionID), {
text: genesetDescriptionString,
});
}
);
});
});
describe.each([
{ withSubset: true, tag: "subset" },
{ withSubset: false, tag: "whole" },
])("GENE crud operations and interactions", (config) => {
test("add a gene to geneset and undo/redo", async () => {
await setup(config);
await addGeneToSet(setToAddGeneTo, geneToAddToSet);
await expandGeneset(setToAddGeneTo);
await assertGeneExistsInGeneset(geneToAddToSet);
await clickOn("undo");
await assertGeneDoesNotExist(geneToAddToSet);
await clickOn("redo");
await assertGeneExistsInGeneset(geneToAddToSet);
});
test("expand gene and brush", async () => {
await setup(config);
await expandGeneset(brushThisGeneGeneset);
await expandGene(geneToBrushAndColorBy);
const histBrushableAreaId = `histogram-${geneToBrushAndColorBy}-plot-brushable-area`;
const coords = await calcDragCoordinates(histBrushableAreaId, {
x1: 0.25,
y1: 0.5,
x2: 0.55,
y2: 0.5,
});
await drag(histBrushableAreaId, coords.start, coords.end);
const cellCount = await getCellSetCount(1);
if (config.withSubset) {
expect(cellCount).toBe(subsetGeneBrushedCellCount);
} else {
expect(cellCount).toBe(geneBrushedCellCount);
}
});
test("color by gene in geneset", async () => {
await setup(config);
await expandGeneset(meanExpressionBrushGenesetName);
await colorByGene(geneToBrushAndColorBy);
await assertColorLegendLabel(geneToBrushAndColorBy);
});
test("delete gene from geneset and undo/redo", async () => {
// We've already deleted the gene
if (config.withSubset) return;
await setup(config);
await expandGeneset(setToRemoveFrom);
await removeGene(geneToRemove);
await assertGeneDoesNotExist(geneToRemove);
await clickOn("undo");
await assertGeneExistsInGeneset(geneToRemove);
await clickOn("redo");
await assertGeneDoesNotExist(geneToRemove);
});
});
describe.each([
{ withSubset: true, tag: "subset", categoricalAnno: true },
{ withSubset: false, tag: "whole", categoricalAnno: true },
])("annotations", (config) => {
test("create a category", async () => {
await setup(config);
const categoryName = `category-created-${config.tag}`;
await assertCategoryDoesNotExist(categoryName);
await createCategory(categoryName);
await assertCategoryExists(categoryName);
});
test("delete a category", async () => {
await setup(config);
await deleteCategory(perTestCategoryName);
await assertCategoryDoesNotExist(perTestCategoryName);
});
test("rename a category", async () => {
await setup(config);
const newCategoryName = `NEW-${config.tag}`;
await renameCategory(perTestCategoryName, newCategoryName);
await assertCategoryDoesNotExist(perTestCategoryName);
await assertCategoryExists(newCategoryName);
});
test("create a label", async () => {
await setup(config);
const labelName = `new-label-${config.tag}`;
await assertLabelDoesNotExist(perTestCategoryName, labelName);
await createLabel(perTestCategoryName, labelName);
await assertLabelExists(perTestCategoryName, labelName);
});
test("delete a label", async () => {
await setup(config);
await deleteLabel(perTestCategoryName, perTestLabelName);
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
});
test("rename a label", async () => {
await setup(config);
const newLabelName = "my-cool-new-label";
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
await assertLabelExists(perTestCategoryName, newLabelName);
});
test("check cell count for a label loaded from file", async () => {
await setup(config);
const duplicateCategoryName = "duplicate";
await duplicateCategory(duplicateCategoryName);
await page.reload({ waitUntil: ["networkidle0", "domcontentloaded"] });
const firstCategoryExpandIcon = await expect(page).toMatchElement(
getTestClass("category-expand")
);
await firstCategoryExpandIcon.click();
const expectedCategoryRow = await expect(page).toMatchElement(
getTestClass("categorical-row")
);
const expectedLabelName = await getInnerText(
expectedCategoryRow,
"categorical-value"
);
const expectedLabelCount = await getInnerText(
expectedCategoryRow,
"categorical-value-count"
);
await expandCategory(duplicateCategoryName);
const expectedCategory = await expect(page).toMatchElement(
getTestClass("category")
);
const actualCategoryRow = await expect(expectedCategory).toMatchElement(
getTestClass("categorical-row")
);
const actualLabelName = await getInnerText(
actualCategoryRow,
"categorical-value"
);
const actualLabelCount = await getInnerText(
actualCategoryRow,
"categorical-value-count"
);
expect(actualLabelName).toBe(expectedLabelName);
expect(actualLabelCount).toBe(expectedLabelCount);
async function getInnerText(element, className) {
return element.$eval(getTestClass(className), (node) => node?.innerText);
}
});
test("assign cells to a label", async () => {
await setup(config);
await expandCategory(perTestCategoryName);
const lassoSelection = await calcDragCoordinates(
"layout-graph",
data.categoryLabel.lasso["coordinates-as-percent"]
);
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
await waitByID("lasso-element", { visible: true });
await clickOn(`${perTestCategoryName}:${perTestLabelName}:see-actions`);
await clickOn(
`${perTestCategoryName}:${perTestLabelName}:add-current-selection-to-this-label`
);
const result = await waitByID(
`categorical-value-count-${perTestCategoryName}-${perTestLabelName}`
);
expect(await result.evaluate((node) => node.innerText)).toBe(
data.categoryLabel.newCount.bySubsetConfig[config.withSubset]
);
});
test("undo/redo category creation", async () => {
await setup(config);
const categoryName = `category-created-undo-${config.tag}`;
await assertCategoryDoesNotExist(categoryName);
await createCategory(categoryName);
await assertCategoryExists(categoryName);
await clickOn("undo");
await assertCategoryDoesNotExist(categoryName);
await clickOn("redo");
await assertCategoryExists(categoryName);
});
test("undo/redo category deletion", async () => {
await setup(config);
const categoryName = `category-deleted-undo-${config.tag}`;
await createCategory(categoryName);
await assertCategoryExists(categoryName);
await deleteCategory(categoryName);
await assertCategoryDoesNotExist(categoryName);
await clickOn("undo");
await assertCategoryExists(categoryName);
await clickOn("redo");
await assertCategoryDoesNotExist(categoryName);
});
test("undo/redo category rename", async () => {
await setup(config);
const newCategoryName = `category-renamed-undo-${config.tag}`;
await assertCategoryDoesNotExist(newCategoryName);
await renameCategory(perTestCategoryName, newCategoryName);
await assertCategoryExists(newCategoryName);
await assertCategoryDoesNotExist(perTestCategoryName);
await clickOn("undo");
await assertCategoryExists(perTestCategoryName);
await assertCategoryDoesNotExist(newCategoryName);
await clickOn("redo");
await assertCategoryExists(newCategoryName);
await assertCategoryDoesNotExist(perTestCategoryName);
});
test("undo/redo label creation", async () => {
await setup(config);
const labelName = `label-created-undo-${config.tag}`;
await assertLabelDoesNotExist(perTestCategoryName, labelName);
await createLabel(perTestCategoryName, labelName);
await assertLabelExists(perTestCategoryName, labelName);
await clickOn("undo");
await assertLabelDoesNotExist(perTestCategoryName);
await clickOn("redo");
await assertLabelExists(perTestCategoryName, labelName);
});
test("undo/redo label deletion", async () => {
await setup(config);
await deleteLabel(perTestCategoryName, perTestLabelName);
await assertLabelDoesNotExist(perTestCategoryName);
await clickOn("undo");
await assertLabelExists(perTestCategoryName, perTestLabelName);
await clickOn("redo");
await assertLabelDoesNotExist(perTestCategoryName);
});
test("undo/redo label rename", async () => {
await setup(config);
const newLabelName = `label-renamed-undo-${config.tag}`;
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
await assertLabelExists(perTestCategoryName, newLabelName);
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
await clickOn("undo");
await assertLabelExists(perTestCategoryName, perTestLabelName);
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
await clickOn("redo");
await assertLabelExists(perTestCategoryName, newLabelName);
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
});
test("stacked bar graph renders", async () => {
await setup(config);
await expandCategory(perTestCategoryName);
await clickOn(`colorby-louvain`);
const labels = await getAllByClass("categorical-row");
const result = await Promise.all(
labels.map((label) => page.evaluate((element) => element.outerHTML, label))
);
expect(result).toMatchSnapshot();
});
test("truncate midpoint whitespace", async () => {
await setup(config);
const newLabelName = "123 456";
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
const value = await waitByID(
`categorical-value-${perTestCategoryName}-${newLabelName}`
);
const result = await page.evaluate((elem) => elem.outerHTML, value);
expect(result).toMatchSnapshot();
});
test("truncate single character", async () => {
await setup(config);
const newLabelName = "T";
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
const value = await waitByID(
`categorical-value-${perTestCategoryName}-${newLabelName}`
);
const result = await page.evaluate((elem) => elem.outerHTML, value);
expect(result).toMatchSnapshot();
});
async function assertCategoryExists(categoryName) {
const handle = await waitByID(`${categoryName}:category-label`);
const result = await handle.evaluate((node) =>
node.getAttribute("aria-label")
);
return expect(result).toBe(categoryName);
}
async function assertLabelExists(categoryName, labelName) {
await expect(page).toMatchElement(
getTestId(`${categoryName}:category-expand`)
);
await expandCategory(categoryName);
const previous = await waitByID(
`categorical-value-${categoryName}-${labelName}`
);
expect(
await previous.evaluate((node) => node.getAttribute("aria-label"))
).toBe(labelName);
}
async function assertLabelDoesNotExist(categoryName, labelName) {
await expandCategory(categoryName);
const result = await page.$(
`[data-testid='categorical-value-${categoryName}-${labelName}']`
);
expect(result).toBeNull();
}
});
+7 -7
View File
@@ -1,10 +1,10 @@
{
"testRunner": "jest-circus/runner",
"preset": "jest-puppeteer",
"testMatch": [
"**/__tests__/**/?(*.)(spec|test).js?(x)"
],
"testURL": "http://localhost/",
"setupFiles": [
"../setupMissingGlobals.js"
]
"testMatch": ["**/__tests__/**/?(*.)(spec|test).js?(x)"],
"setupFiles": ["../setupMissingGlobals.js"],
"setupFilesAfterEnv": ["expect-puppeteer", "./puppeteer.setup.js"],
"globalSetup": "../globalSetup.js",
"globalTeardown": "jest-environment-puppeteer/teardown",
"testEnvironment": "./screenshot_env.js"
}
-118
View File
@@ -1,118 +0,0 @@
/*
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);
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* `client/jest-puppeteer.config.js` is for configuring Puppeteer's launch config options
* `client/__tests__/e2e/puppeteer.setup.js` is for configuring `jest`, `browser`,
* and `page` objects
*/
import { setDefaultOptions } from "expect-puppeteer";
import { isDebug, isDev } from "./config";
import * as ENV_DEFAULT from "../../../environment.default.json";
// (thuang): This is the max time a test can take to run.
// Since when debugging, we run slowMo and !headless, this means
// a test can take more time to finish, so we don't want
// jest to shut off the test too soon
jest.setTimeout(2 * 60 * 1000);
setDefaultOptions({ timeout: 20 * 1000 });
jest.retryTimes(ENV_DEFAULT.RETRY_ATTEMPTS);
beforeEach(async () => {
await jestPuppeteer.resetBrowser();
const userAgent = await browser.userAgent();
await page.setUserAgent(`${userAgent}bot`);
await page._client.send("Animation.setPlaybackRate", { playbackRate: 12 });
page.on("pageerror", (err) => {
throw new Error(`Console error: ${err}`);
});
page.on("error", (err) => {
throw new Error(`Console error: ${err}`);
});
page.on("console", async (msg) => {
if (isDev || isDebug) {
// If there is a console.error but an error is not thrown, this will ensure the test fails
console.log(`PAGE LOG: ${msg.text()}`);
if (msg.type() === "error") {
// TODO: chromium does not currently support the CSP directive on the
// line below, so we swallow this error. Remove this when the test
// suite uses a browser version that supports this directive.
if (
msg.text() ===
"Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n"
) {
return;
}
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}`);
}
}
});
});
+108 -195
View File
@@ -1,211 +1,124 @@
export const puppeteerUtils = puppeteerPage => ({
async waitByID(testid, props = {}) {
return await puppeteerPage.waitForSelector(
`[data-testid='${testid}']`,
props
);
},
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
export function getTestId(id) {
return `[data-testid='${id}']`;
}
async waitByClass(testclass, props = {}) {
return await puppeteerPage.waitForSelector(
`[data-testclass='${testclass}']`,
props
);
},
export function getTestClass(className) {
return `[data-testclass='${className}']`;
}
async waitForAllByIds(testids, props = {}) {
await Promise.all(
testids.map(testid =>
puppeteerPage.waitForSelector(`[data-testid='${testid}']`)
)
);
},
export async function waitByID(testId, props = {}) {
return page.waitForSelector(getTestId(testId), props);
}
async getAllByClass(testclass, props = {}) {
const elements = await puppeteerPage.$$eval(
`[data-testclass=${testclass}]`,
els => {
return els.map(el => {
return el.dataset.testid;
});
}
);
return elements;
},
export async function waitByClass(testClass, props = {}) {
return page.waitForSelector(`[data-testclass='${testClass}']`, props);
}
async typeInto(testid, text) {
// only works for text without special characters
await this.waitByID(testid);
const selector = `[data-testid='${testid}']`;
// type ahead can be annoying if you don't pause before you type
await puppeteerPage.click(selector);
await puppeteerPage.waitFor(200);
await puppeteerPage.type(selector, text);
},
export async function waitForAllByIds(testIds) {
await Promise.all(
testIds.map((testId) => page.waitForSelector(getTestId(testId)))
);
}
async clearInputAndTypeInto(testid, text) {
await this.waitByID(testid);
const selector = `[data-testid='${testid}']`;
// only works for text without special characters
// type ahead can be annoying if you don't pause before you type
await puppeteerPage.click(selector);
await puppeteerPage.waitFor(200);
// select all
export async function getAllByClass(testClass) {
return page.$$(`[data-testclass=${testClass}]`);
}
await puppeteerPage.click(selector, { clickCount: 3 });
await puppeteerPage.keyboard.type("Backspace");
await puppeteerPage.type(selector, text);
},
export async function typeInto(testId, text) {
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
// only works for text without special characters
await waitByID(testId);
const selector = getTestId(testId);
// type ahead can be annoying if you don't pause before you type
await page.click(selector);
await page.waitForTimeout(200);
await page.type(selector, text);
}
async clickOn(testid) {
await this.waitByID(testid);
await puppeteerPage.click(`[data-testid='${testid}']`);
await puppeteerPage.waitFor(50);
},
export async function clearInputAndTypeInto(testId, text) {
await waitByID(testId);
const selector = getTestId(testId);
// only works for text without special characters
// type ahead can be annoying if you don't pause before you type
await page.click(selector);
await page.waitForTimeout(200);
// select all
await page.click(selector, { clickCount: 3 });
await page.keyboard.press("Backspace");
await page.type(selector, text);
}
async getOneElementInnerHTML(selector) {
await puppeteerPage.waitForSelector(selector);
let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
return text;
},
export async function clickOn(testId, options = {}) {
await expect(page).toClick(getTestId(testId), options);
}
async getOneElementInnerText(selector) {
await puppeteerPage.waitForSelector(selector);
let text = await puppeteerPage.$eval(selector, el => el.innerText);
return text;
}
});
/**
* (thuang): There are times when Puppeteer clicks on a button and the page doesn't respond.
* So I added clickOnUntil() to retry clicking until a given condition is met.
*/
export async function clickOnUntil(testId, assert) {
const MAX_RETRY = 10;
const WAIT_FOR_MS = 200;
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();
},
let retry = 0;
async getAllHistograms(testclass, testids) {
const histTestIds = testids.map(tid => `histogram-${tid}`);
// these load asynchronously, so we need to wait for each histogram individually
await puppeteerUtils(puppeteerPage).waitForAllByIds(histTestIds);
const allHistograms = await puppeteerUtils(puppeteerPage).getAllByClass(
testclass
);
return allHistograms.map(hist =>
hist.substr("histogram_".length, hist.length)
);
},
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);
}
while (retry < MAX_RETRY) {
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 {}
},
await clickOn(testId);
await assert();
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;
},
break;
} catch (error) {
retry += 1;
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}`
);
await page.waitForTimeout(WAIT_FOR_MS);
}
},
async reset() {
await puppeteerUtils(puppeteerPage).clickOn("reset");
// loading state never actually happens, reset is too fast
await page.waitFor(200);
},
async clip(min = 0, max = 100) {
await puppeteerUtils(puppeteerPage).clickOn("visualization-settings");
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
"clip-min-input",
min
);
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
"clip-max-input",
max
);
await puppeteerUtils(puppeteerPage).clickOn("clip-commit");
}
});
if (retry === MAX_RETRY) {
throw Error("clickOnUntil() assertion failed!");
}
}
export async function getOneElementInnerHTML(selector, options = {}) {
await page.waitForSelector(selector, options);
return page.$eval(selector, (el) => el.innerHTML);
}
export async function getOneElementInnerText(selector) {
expect(page).toMatchElement(selector);
return page.$eval(selector, (el) => el.innerText);
}
export async function getElementCoordinates(testId) {
return page.$eval(getTestId(testId), (elem) => {
const { left, top } = elem.getBoundingClientRect();
return [left, top];
});
}
async function nameNewAnnotation() {
if (await isElementPresent(getTestId("annotation-dialog"))) {
await typeInto("new-annotation-name", "ignoreE2E");
await clickOn("submit-annotation");
// wait for the page to load
await waitByClass("autosave-complete");
}
}
export async function goToPage(url) {
await page.goto(url, {
waitUntil: "networkidle0",
});
await nameNewAnnotation();
}
export async function isElementPresent(selector, options) {
return Boolean(await page.$(selector, options));
}
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
+36
View File
@@ -0,0 +1,36 @@
const PuppeteerEnvironment = require("jest-environment-puppeteer");
require("jest-circus");
const ENV_DEFAULT = require("../../../environment.default.json");
const takeScreenshot = require("./takeScreenshot");
class ScreenshotEnvironment extends PuppeteerEnvironment {
async handleTestEvent(event, state) {
if (["test_start", "test_done"].includes(event.name)) {
console.log("------------------event name:\n", event.name);
console.log("~~~~ Current test errors\n", new Date(), event.test.errors);
console.log("~~~~ Current test\n", new Date(), event.test);
}
if (event.name === "error") {
console.log("error event:", JSON.stringify(event));
}
if (event.name === "test_fn_failure" || event.name === "hook_failure") {
console.log("------------------event name:\n", event.name);
console.log(">>>> Current state\n", new Date(), state);
console.log("===> Failure event\n", new Date(), event);
// (thuang): We only want to take screenshot on the last try
if (
state.currentlyRunningTest.invocations <= ENV_DEFAULT.RETRY_ATTEMPTS
) {
return;
}
await takeScreenshot(state.currentlyRunningTest.name, this.global.page);
}
}
}
module.exports = ScreenshotEnvironment;
+17
View File
@@ -0,0 +1,17 @@
function toFilename(name) {
return name.replace(/[^a-z0-9.-]+/gi, "-");
}
async function takeScreenshot(currentTestName, page) {
const testName = toFilename(currentTestName);
// Take a screenshot at the point of failure
const date = new Date().toISOString();
const screenshotName = `${date}-${testName}.png`;
await page.screenshot({
path: `./__tests__/screenshots/ignoreE2E-screenshot-${screenshotName}`,
});
}
module.exports = takeScreenshot;
+19
View File
@@ -0,0 +1,19 @@
server:
app:
force_https: true
port: 5005
dataset:
presentation:
max_categories: 1000
custom_colors: true
user_annotations:
enable: false
type: local_file_csv
local_file_csv:
directory: null
file: null
embeddings:
names: []
+5
View File
@@ -0,0 +1,5 @@
const { setup } = require("jest-environment-puppeteer");
module.exports = async () => {
await setup();
};
+3 -3
View File
@@ -26,7 +26,7 @@ describe("cascade", () => {
expect(nextSharedState).toStrictEqual({});
expect(prevSharedState).toBe(topLevelState);
return 0;
}
},
],
[
"bar",
@@ -36,8 +36,8 @@ describe("cascade", () => {
expect(nextSharedState).toStrictEqual({ foo: 0 });
expect(prevSharedState).toBe(topLevelState);
return 99;
}
]
},
],
]);
const nextState = reducer(topLevelState, topLevelAction);
+524
View File
@@ -0,0 +1,524 @@
import genesetsReducer from "../../src/reducers/genesets";
describe("initial reducer state", () => {
test("some other action", () => {
expect(genesetsReducer(undefined, { type: "foo" })).toMatchObject({
initialized: false,
lastTid: undefined,
genesets: new Map(),
});
});
});
describe("geneset: initial load", () => {
test("missing JSON response", () => {
expect(() =>
genesetsReducer(undefined, {
type: "geneset: initial load",
})
).toThrow("missing or malformed JSON response");
});
test("empty geneset", () => {
expect(
genesetsReducer(undefined, {
type: "geneset: initial load",
data: {
tid: 0,
genesets: [],
},
})
).toMatchObject({
initialized: true,
lastTid: 0,
genesets: new Map(),
});
});
test("non-empty geneset", () => {
expect(
genesetsReducer(undefined, {
type: "geneset: initial load",
data: {
tid: 99,
genesets: [
{
geneset_name: "G1",
genes: [{ gene_symbol: "F5" }],
},
{
geneset_name: "G2",
geneset_description: "G2 desc",
genes: [{ gene_symbol: "F6" }],
},
{
geneset_name: "G3",
geneset_description: "G3 desc",
genes: [{ gene_symbol: "F7", gene_description: "gene desc" }],
},
],
},
})
).toMatchObject({
initialized: true,
lastTid: 99,
genesets: new Map([
[
"G1",
{
genesetName: "G1",
genesetDescription: "",
genes: new Map([["F5", { geneSymbol: "F5", geneDescription: "" }]]),
},
],
[
"G2",
{
genesetName: "G2",
genesetDescription: "G2 desc",
genes: new Map([["F6", { geneSymbol: "F6", geneDescription: "" }]]),
},
],
[
"G3",
{
genesetName: "G3",
genesetDescription: "G3 desc",
genes: new Map([
["F7", { geneSymbol: "F7", geneDescription: "gene desc" }],
]),
},
],
]),
});
});
});
describe("geneset: create", () => {
const initialState = genesetsReducer(undefined, {
type: "geneset: initial load",
data: {
tid: 0,
genesets: [],
},
});
test("simple create", () => {
expect(
genesetsReducer(initialState, {
type: "geneset: create",
genesetName: "a geneset",
genesetDescription: "",
})
).toMatchObject({
...initialState,
genesets: new Map([
[
"a geneset",
{
genesetName: "a geneset",
genesetDescription: "",
genes: new Map(),
},
],
]),
});
});
test("error - duplicate name", () => {
expect(() => {
genesetsReducer(
genesetsReducer(initialState, {
type: "geneset: create",
genesetName: "foo",
genesetDescription: "foo",
}),
{
type: "geneset: create",
genesetName: "foo",
genesetDescription: "bar",
}
);
}).toThrow("name already defined");
});
test("error - missing required action values", () => {
expect(() => {
genesetsReducer(initialState, {
type: "geneset: create",
genesetDescription: "foo",
});
}).toThrow();
expect(() => {
genesetsReducer(initialState, {
type: "geneset: create",
genesetName: "foo",
});
}).toThrow("name or description unspecified");
});
});
describe("geneset: delete", () => {
const initialState = genesetsReducer(undefined, {
type: "geneset: initial load",
data: {
tid: 0,
genesets: [],
},
});
test("simple delete", () => {
expect(
genesetsReducer(
genesetsReducer(initialState, {
type: "geneset: create",
genesetName: "foo",
genesetDescription: "foo",
}),
{
type: "geneset: delete",
genesetName: "foo",
}
)
).toMatchObject({
initialized: true,
lastTid: 0,
genesets: new Map(),
});
});
test("error - missing name", () => {
expect(() => {
genesetsReducer(initialState, {
type: "geneset: delete",
genesetName: "foo",
});
}).toThrow("name does not exist");
});
});
describe("geneset: update", () => {
const initialState = genesetsReducer(undefined, {
type: "geneset: initial load",
data: {
tid: 0,
genesets: [],
},
});
test("simple update", () => {
expect(
genesetsReducer(
genesetsReducer(
genesetsReducer(initialState, {
type: "geneset: create",
genesetName: "foo1",
genesetDescription: "foo1",
}),
{
type: "geneset: create",
genesetName: "foo2",
genesetDescription: "foo2",
}
),
{
type: "geneset: update",
genesetName: "foo1",
update: {
genesetName: "bar",
genesetDescription: "bar",
},
}
)
).toMatchObject({
initialized: true,
lastTid: 0,
genesets: new Map([
[
"bar",
{ genesetName: "bar", genesetDescription: "bar", genes: new Map() },
],
[
"foo2",
{ genesetName: "foo2", genesetDescription: "foo2", genes: new Map() },
],
]),
});
});
test("error - unknown name", () => {
expect(() => {
genesetsReducer(initialState, {
type: "geneset: update",
genesetName: "foo",
update: {
genesetName: "foo",
genesetDescription: "bar",
},
});
}).toThrow("name unspecified or does not exist");
});
test("error - duplicate name", () => {
expect(() => {
genesetsReducer(
genesetsReducer(initialState, {
type: "geneset: create",
genesetName: "foo",
genesetDescription: "foo",
}),
{
type: "geneset: update",
genesetName: "foo",
update: {
genesetName: "foo",
genesetDescription: "foo",
},
}
);
}).toThrow("update specified existing name and description");
});
});
describe("geneset: add genes", () => {
const initialState = genesetsReducer(
genesetsReducer(undefined, {
type: "geneset: initial load",
data: {
tid: 0,
genesets: [],
},
}),
{
type: "geneset: create",
genesetName: "test",
genesetDescription: "",
}
);
test("add a gene", () => {
expect(
genesetsReducer(initialState, {
type: "geneset: add genes",
genesetName: "test",
genes: [{ geneSymbol: "F5" }],
})
).toMatchObject({
...initialState,
genesets: new Map([
[
"test",
{
genesetName: "test",
genesetDescription: "",
genes: new Map([["F5", { geneSymbol: "F5", geneDescription: "" }]]),
},
],
]),
});
expect(
genesetsReducer(initialState, {
type: "geneset: add genes",
genesetName: "test",
genes: [
{ geneSymbol: "F5", geneDescription: "desc" },
{ geneSymbol: "SET1", geneDescription: "" },
],
})
).toMatchObject({
...initialState,
genesets: new Map([
[
"test",
{
genesetName: "test",
genesetDescription: "",
genes: new Map([
["F5", { geneSymbol: "F5", geneDescription: "desc" }],
["SET1", { geneSymbol: "SET1", geneDescription: "" }],
]),
},
],
]),
});
});
test("no such geneset error", () => {
expect(() => {
genesetsReducer(initialState, {
type: "geneset: add genes",
genesetName: "mumble",
genes: [],
});
}).toThrow("geneset name does not exist");
});
});
describe("geneset: delete genes", () => {
const initialState = genesetsReducer(
genesetsReducer(
genesetsReducer(undefined, {
type: "geneset: initial load",
data: {
tid: 0,
genesets: [],
},
}),
{
type: "geneset: create",
genesetName: "test",
genesetDescription: "",
}
),
{
type: "geneset: add genes",
genesetName: "test",
genes: [{ geneSymbol: "F5" }],
}
);
test("simple", () => {
expect(
genesetsReducer(initialState, {
type: "geneset: delete genes",
genesetName: "test",
geneSymbols: ["F5"],
})
).toMatchObject({
...initialState,
genesets: new Map([
[
"test",
{
genesetName: "test",
genesetDescription: "",
genes: new Map(),
},
],
]),
});
});
test("no such geneset error", () => {
expect(() => {
genesetsReducer(initialState, {
type: "geneset: delete genes",
genesetName: "mumble",
geneSymbols: [],
});
}).toThrow("name does not exist");
});
});
describe("geneset: set gene description", () => {
const initialState = genesetsReducer(
genesetsReducer(
genesetsReducer(undefined, {
type: "geneset: initial load",
data: {
tid: 0,
genesets: [],
},
}),
{
type: "geneset: create",
genesetName: "test",
genesetDescription: "",
}
),
{
type: "geneset: add genes",
genesetName: "test",
genes: [{ geneSymbol: "F5" }],
}
);
test("simple set", () => {
expect(
genesetsReducer(initialState, {
type: "geneset: set gene description",
genesetName: "test",
update: {
geneSymbol: "F5",
geneDescription: "mumble",
},
})
).toMatchObject({
...initialState,
genesets: new Map([
[
"test",
{
genesetName: "test",
genesetDescription: "",
genes: new Map([
["F5", { geneSymbol: "F5", geneDescription: "mumble" }],
]),
},
],
]),
});
});
test("no such geneset error", () => {
expect(() => {
genesetsReducer(initialState, {
type: "geneset: set gene description",
genesetName: "does not exist",
update: {
geneSymbol: "F5",
geneDescription: "mumble",
},
});
}).toThrow("geneset name does not exist");
});
test("no such gene error", () => {
expect(() => {
genesetsReducer(initialState, {
type: "geneset: set gene description",
genesetName: "test",
update: {
geneSymbol: "NO SUCH GENE",
geneDescription: "mumble",
},
});
}).toThrow("no such gene");
});
});
describe("geneset: set tid", () => {
test("simple set", () => {
expect(
genesetsReducer(undefined, {
type: "geneset: set tid",
tid: 1,
})
).toMatchObject({ lastTid: 1 });
});
test("not a number error", () => {
expect(() => {
genesetsReducer(
{ lastTid: 1 },
{
type: "geneset: set tid",
tid: "0",
}
);
}).toThrow("must be a positive integer");
});
test("decrement error", () => {
expect(() => {
genesetsReducer(
{ lastTid: 1 },
{
type: "geneset: set tid",
tid: 0,
}
);
}).toThrow("may not be decremented");
});
});
@@ -0,0 +1,75 @@
import genesetsUIReducer from "../../src/reducers/genesetsUI";
// Format: GeneSetsUI(state,action)
const initialState = {
createGenesetModeActive: false,
isEditingGenesetName: false,
isAddingGenesToGeneset: false,
};
/* initial */
describe("geneset UI states", () => {
test("initial state, some other action", () => {
expect(
genesetsUIReducer(undefined, {
type: "foo",
})
).toMatchObject(initialState);
});
test("geneset: activate add new geneset mode", () => {
expect(
genesetsUIReducer(initialState, {
type: "geneset: activate add new geneset mode",
})
).toMatchObject({
createGenesetModeActive: true,
isEditingGenesetName: false,
isAddingGenesToGeneset: false,
});
});
test("geneset: disable create geneset mode", () => {
expect(
genesetsUIReducer(undefined, { isEditingGenesetName: false })
).toMatchObject(initialState);
});
test("activate add new genes mode", () => {
expect(
genesetsUIReducer(undefined, {
type: "geneset: activate add new genes mode",
geneset: "a geneset name",
})
).toMatchObject({
createGenesetModeActive: false,
isEditingGenesetName: false,
isAddingGenesToGeneset: "a geneset name",
});
});
test("disable create geneset mode", () => {
expect(
genesetsUIReducer(undefined, {
type: "geneset: disable create geneset mode",
})
).toMatchObject(initialState);
});
test("activate rename geneset mode", () => {
expect(
genesetsUIReducer(undefined, {
type: "geneset: activate rename geneset mode",
data: "a geneset name",
})
).toMatchObject({
createGenesetModeActive: false,
isEditingGenesetName: "a geneset name",
isAddingGenesToGeneset: false,
});
});
test("disable rename geneset mode", () => {
expect(
genesetsUIReducer(undefined, {
type: "geneset: disable rename geneset mode",
})
).toMatchObject(initialState);
});
});
+3 -7
View File
@@ -23,9 +23,7 @@ describe("create", () => {
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 reducer = (state) => ({ a: state.a + 1, b: state.b + 1 });
const undoableReducer = undoable(reducer, ["a"]);
const s1 = undoableReducer(initialState, { type: "test" });
@@ -43,9 +41,7 @@ describe("undo", () => {
describe("redo", () => {
const initialState = { a: 0, b: 1000 };
const reducer = state => {
return { a: state.a + 1, b: state.b + 1 };
};
const reducer = (state) => ({ a: state.a + 1, b: state.b + 1 });
let UR;
beforeEach(() => {
@@ -58,7 +54,7 @@ describe("redo", () => {
// verify undo->redo reverts state.
const s2 = UR(UR(s1, { type: "@@undoable/undo" }), {
type: "@@undoable/redo"
type: "@@undoable/redo",
});
expect(s2).toMatchObject({ a: 1, b: 1001 });
+4
View File
@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore
+6 -7
View File
@@ -11,14 +11,10 @@ describe("rangeEncodeIndices", () => {
test("sorted flag", () => {
expect(rangeEncodeIndices([1, 9, 432], 10, true)).toMatchObject([
1,
9,
432
1, 9, 432,
]);
expect(rangeEncodeIndices([1, 9, 432], 10, false)).toMatchObject([
1,
9,
432
1, 9, 432,
]);
expect(rangeEncodeIndices([0, 1, 2, 3, 9, 10, 432], 2, true)).toMatchObject(
[[0, 3], [9, 10], 432]
@@ -34,7 +30,10 @@ describe("rangeEncodeIndices", () => {
).toMatchObject([[3, 7], [10, 12], 99]);
expect(
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12], 3, false)
).toMatchObject([[3, 7], [10, 12]]);
).toMatchObject([
[3, 7],
[10, 12],
]);
expect(
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12], 3, false)
).toMatchObject([0, [3, 7], [10, 12]]);
@@ -0,0 +1,322 @@
// these TWO statements MUST be first in the file, before any other imports
import { enableFetchMocks } from "jest-fetch-mock";
import * as serverMocks from "./serverMocks";
// OK, continue on!
import {
AnnoMatrixLoader,
clip,
isubset,
isubsetMask,
} from "../../../src/annoMatrix";
import { Dataframe } from "../../../src/util/dataframe";
enableFetchMocks();
describe("AnnoMatrix", () => {
let annoMatrix;
beforeEach(async () => {
fetch.resetMocks(); // reset all fetch mocking state
annoMatrix = new AnnoMatrixLoader(
serverMocks.baseDataURL,
serverMocks.schema.schema
);
});
describe("basics", () => {
test("annomatrix static checks", () => {
expect(annoMatrix).toBeDefined();
expect(annoMatrix.schema).toMatchObject(serverMocks.schema.schema);
expect(annoMatrix.nObs).toEqual(serverMocks.schema.schema.dataframe.nObs);
expect(annoMatrix.nVar).toEqual(serverMocks.schema.schema.dataframe.nVar);
expect(annoMatrix.isView).toBeFalsy();
expect(annoMatrix.viewOf).toBeUndefined();
expect(annoMatrix.rowIndex).toBeDefined();
});
test("simple single column fetch", async () => {
fetch.once(serverMocks.annotationsObs(["name_0"]));
const df = await annoMatrix.fetch("obs", "name_0");
expect(df).toBeInstanceOf(Dataframe);
expect(df.colIndex.labels()).toEqual(["name_0"]);
expect(df.dims).toEqual([annoMatrix.nObs, 1]);
});
test("simple multi column fetch", async () => {
fetch
.once(serverMocks.annotationsObs(["name_0"]))
.once(serverMocks.annotationsObs(["n_genes"]));
await expect(
annoMatrix.fetch("obs", ["name_0", "n_genes"])
).resolves.toBeInstanceOf(Dataframe);
});
describe("fetch from field", () => {
const getLastTwo = async (field) => {
const columnNames = annoMatrix.getMatrixColumns(field).slice(-2);
fetch.mockResponses(...columnNames.map(() => serverMocks.responder));
await expect(
annoMatrix.fetch(field, columnNames)
).resolves.toBeInstanceOf(Dataframe);
};
test("obs", async () => getLastTwo("obs"));
test("var", async () => getLastTwo("var"));
test("emb", async () => getLastTwo("emb"));
});
test("fetch - test all query forms", async () => {
// single string is a column name
fetch.once(serverMocks.annotationsObs(["n_genes"]));
await expect(annoMatrix.fetch("obs", "n_genes")).resolves.toBeInstanceOf(
Dataframe
);
// array of column names, expecting n_genes to be cached.
fetch.once(serverMocks.annotationsObs(["percent_mito"]));
await expect(
annoMatrix.fetch("obs", ["n_genes", "percent_mito"])
).resolves.toBeInstanceOf(Dataframe);
// more complex value filter query, enumerated
fetch.once(serverMocks.responder);
await expect(
annoMatrix.fetch("X", {
where: {
field: "var",
column: annoMatrix.schema.annotations.var.index,
value: "TYMP",
},
})
).resolves.toBeInstanceOf(Dataframe);
// more complex value filter query, range
const varIndex = annoMatrix.schema.annotations.var.index;
fetch
.once(
serverMocks.withExpected("/data/var", [[`var:${varIndex}`, "SUMO3"]])
)
.once(
serverMocks.withExpected("/data/var", [[`var:${varIndex}`, "TYMP"]])
);
await expect(
annoMatrix.fetch("X", [
{
where: {
field: "var",
column: varIndex,
value: "SUMO3",
},
},
{
where: {
field: "var",
column: varIndex,
value: "TYMP",
},
},
])
).resolves.toBeInstanceOf(Dataframe);
// XXX inspect the wherecache?
});
test("push and pop views", async () => {
const am1 = clip(annoMatrix, 0.1, 0.9);
expect(am1.viewOf).toBe(annoMatrix);
expect(am1.nObs).toEqual(annoMatrix.nObs);
expect(am1.nVar).toEqual(annoMatrix.nVar);
expect(am1.rowIndex).toBe(annoMatrix.rowIndex);
const am2 = clip(annoMatrix, 0.1, 0.9);
expect(am2.viewOf).toBe(annoMatrix);
expect(am2).not.toBe(am1);
expect(am2.rowIndex).toBe(annoMatrix.rowIndex);
});
test("schema accessors", () => {
expect(annoMatrix.getMatrixFields()).toEqual(
expect.arrayContaining(["X", "obs", "emb", "var"])
);
expect(annoMatrix.getMatrixColumns("obs")).toEqual(
expect.arrayContaining(["name_0", "n_genes", "louvain"])
);
expect(annoMatrix.getColumnSchema("emb", "umap")).toEqual({
name: "umap",
dims: ["umap_0", "umap_1"],
type: "float32",
});
expect(annoMatrix.getColumnDimensions("emb", "umap")).toEqual([
"umap_0",
"umap_1",
]);
});
/*
test the mask & label access to subset via isubset and isubsetMask
*/
test("isubset", async () => {
const rowList = [0, 10];
const rowMask = new Uint8Array(annoMatrix.nObs);
for (let i = 0; i < rowList.length; i += 1) {
rowMask[rowList[i]] = 1;
}
const am1 = isubset(annoMatrix, rowList);
const am2 = isubsetMask(annoMatrix, rowMask);
expect(am1).not.toBe(am2);
expect(am1.nObs).toEqual(2);
expect(am1.nObs).toEqual(am2.nObs);
expect(am1.nVar).toEqual(am2.nVar);
fetch
.once(serverMocks.annotationsObs(["n_genes"]))
.once(serverMocks.annotationsObs(["n_genes"]));
const ng1 = await am1.fetch("obs", "n_genes");
const ng2 = await am2.fetch("obs", "n_genes");
expect(ng1).toHaveLength(ng2.length);
expect(ng1.colIndex.labels()).toEqual(ng2.colIndex.labels());
expect(ng1.col("n_genes").asArray()).toEqual(
ng2.col("n_genes").asArray()
);
});
});
describe("add/drop column", () => {
async function addDrop(base) {
expect(base.getMatrixColumns("obs")).not.toContain("foo");
fetch.mockRejectOnce(new Error("unknown column name"));
await expect(base.fetch("obs", "foo")).rejects.toThrow(
"unknown column name"
);
/* add */
const am1 = base.addObsColumn(
{ name: "foo", type: "float32", writable: true },
Float32Array,
0
);
expect(base.getMatrixColumns("obs")).not.toContain("foo");
expect(am1.getMatrixColumns("obs")).toContain("foo");
const foo = await am1.fetch("obs", "foo");
expect(foo).toBeDefined();
expect(foo).toBeInstanceOf(Dataframe);
expect(foo).toHaveLength(am1.nObs);
expect(foo.col("foo").asArray()).toEqual(
new Float32Array(am1.nObs).fill(0)
);
/* drop */
const am2 = am1.dropObsColumn("foo");
expect(base.getMatrixColumns("obs")).not.toContain("foo");
expect(am2.getMatrixColumns("obs")).not.toContain("foo");
fetch.mockRejectOnce(new Error("unknown column name"));
await expect(am2.fetch("obs", "foo")).rejects.toThrow(
"unknown column name"
);
}
test("add/drop column, without view", async () => {
await addDrop(annoMatrix);
});
test("add/drop column, with view", async () => {
const am1 = clip(annoMatrix, 0.1, 0.9);
await addDrop(am1);
const am2 = isubset(am1, [0, 1, 2, 20, 30, 400]);
await addDrop(am2);
const am3 = isubset(annoMatrix, [10, 0, 7, 3]);
await addDrop(am3);
const am4 = clip(am3, 0, 1);
await addDrop(am4);
fetch.mockResponse(serverMocks.responder);
await am1.fetch("obs", am1.getMatrixColumns("obs"));
await am2.fetch("obs", am2.getMatrixColumns("obs"));
await am3.fetch("obs", am3.getMatrixColumns("obs"));
await am4.fetch("obs", am4.getMatrixColumns("obs"));
fetch.resetMocks();
await addDrop(am1);
await addDrop(am2);
await addDrop(am3);
await addDrop(am4);
});
});
describe("setObsColumnValues", () => {
async function addSetDrop(base) {
/* add column */
let am = base.addObsColumn(
{
name: "test",
type: "categorical",
categories: ["unassigned", "red", "green"],
writable: true,
},
Array,
"unassigned"
);
const testVal = await am.fetch("obs", "test");
expect(testVal.col("test").asArray()).toEqual(
new Array(am.nObs).fill("unassigned")
);
/* set values in column */
const whichRows = [1, 2, 10];
const am1 = await am.setObsColumnValues("test", whichRows, "yo");
const testVal1 = await am1.fetch("obs", "test");
const expt = new Array(am1.nObs).fill("unassigned");
for (let i = 0; i < whichRows.length; i += 1) {
const offset = am1.rowIndex.getOffset(whichRows[i]);
expt[offset] = "yo";
}
expect(testVal1).not.toBe(testVal);
expect(testVal1.col("test").asArray()).toEqual(expt);
expect(am1.getColumnSchema("obs", "test").type).toBe("categorical");
expect(am1.getColumnSchema("obs", "test").categories).toEqual(
expect.arrayContaining(["unassigned", "red", "green", "yo"])
);
/* drop column */
fetch.mockRejectOnce(new Error("unknown column name"));
am = am1.dropObsColumn("test");
await expect(am.fetch("obs", "test")).rejects.toThrow(
"unknown column name"
);
}
test("set, without a view", async () => {
await addSetDrop(annoMatrix);
});
test("set, with a view", async () => {
const am1 = clip(annoMatrix, 0.1, 0.9);
await addSetDrop(am1);
const am2 = isubset(am1, [0, 1, 2, 10, 20, 30, 400]);
await addSetDrop(am2);
const am3 = isubset(annoMatrix, [10, 1, 0, 30, 2]);
await addSetDrop(am3);
fetch.mockResponse(serverMocks.responder);
await am1.fetch("obs", am1.getMatrixColumns("obs"));
await am2.fetch("obs", am2.getMatrixColumns("obs"));
await am3.fetch("obs", am3.getMatrixColumns("obs"));
await addSetDrop(am1);
await addSetDrop(am2);
await addSetDrop(am3);
});
});
});
@@ -0,0 +1,692 @@
// these TWO statements MUST be first in the file, before any other imports
import { enableFetchMocks } from "jest-fetch-mock";
import * as serverMocks from "./serverMocks";
// OK, continue on!
import obsLouvain from "./louvain.json";
import obsNGenes from "./n_genes.json";
import embUmap from "./umap.json";
import {
AnnoMatrixLoader,
AnnoMatrixObsCrossfilter,
isubsetMask,
} from "../../../src/annoMatrix";
import { rangeFill } from "../../../src/util/range";
enableFetchMocks();
describe("AnnoMatrixCrossfilter", () => {
let annoMatrix;
let crossfilter;
beforeEach(async () => {
fetch.resetMocks(); // reset all fetch mocking state
annoMatrix = new AnnoMatrixLoader(
serverMocks.baseDataURL,
serverMocks.schema.schema
);
crossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
});
test("initial state of crossfilter", () => {
const { nObs } = annoMatrix;
expect(crossfilter).toBeDefined();
expect(crossfilter.size()).toEqual(nObs);
expect(crossfilter.annoMatrix).toBe(annoMatrix);
/* by default, everything should be selected, even if no data in cache */
expect(crossfilter.countSelected()).toEqual(nObs);
expect(crossfilter.allSelectedLabels()).toEqual(
rangeFill(new Int32Array(nObs))
);
expect(crossfilter.allSelectedMask()).toEqual(new Uint8Array(nObs).fill(1));
expect(crossfilter.fillByIsSelected(new Uint8Array(nObs), 2, 1)).toEqual(
new Uint8Array(nObs).fill(2)
);
});
describe("select", () => {
/*
test the selection state via crossfilter proxy
*/
test("select loads index", async () => {
/*
Select should transparently load/create dimension index.
Internal dimension names are field/col:col:col..., eg,
obs:louvain
emb:umap_0:umap_1
*/
expect(crossfilter.obsCrossfilter.dimensionNames()).toEqual([]);
expect(
crossfilter.obsCrossfilter.hasDimension("obs/louvain")
).toBeFalsy();
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
let newCrossfilter = await crossfilter.select("obs", "louvain", {
mode: "none",
});
expect(newCrossfilter.countSelected()).toEqual(0);
expect(
newCrossfilter.obsCrossfilter.hasDimension("obs/louvain")
).toBeTruthy();
expect(fetch.mock.calls).toHaveLength(1);
newCrossfilter = await crossfilter.select("obs", "louvain", {
mode: "all",
});
expect(newCrossfilter.countSelected()).toEqual(annoMatrix.nObs);
});
test("simple column select", async () => {
let xfltr;
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
xfltr = await crossfilter.select("obs", "louvain", {
mode: "exact",
values: ["NK cells", "B cells"],
});
expect(xfltr).toBeDefined();
expect(xfltr.countSelected()).toEqual(496);
expect(xfltr.allSelectedMask()).toEqual(
Uint8Array.from(
obsLouvain.map((val) =>
val === "NK cells" || val === "B cells" ? 1 : 0
)
)
);
expect(xfltr.allSelectedLabels()).toEqual(
Int32Array.from(
obsLouvain.reduce((acc, val, idx) => {
if (val === "NK cells" || val === "B cells") acc.push(idx);
return acc;
}, [])
)
);
expect(
xfltr.fillByIsSelected(new Uint8Array(annoMatrix.nObs), 3, 1)
).toEqual(
Uint8Array.from(
obsLouvain.map((val) =>
val === "NK cells" || val === "B cells" ? 3 : 1
)
)
);
const df = await annoMatrix.fetch("obs", "louvain");
const values = df.col("louvain").asArray();
const selected = xfltr.allSelectedMask();
values.every(
(val, idx) => !["NK cells", "B cells"].includes(val) !== !selected[idx]
);
fetch.once(
serverMocks.dataframeResponse(["n_genes"], [new Int32Array(obsNGenes)])
);
xfltr = await xfltr.select("obs", "n_genes", {
mode: "range",
lo: 0,
hi: 500,
inclusive: false,
});
expect(xfltr.countSelected()).toEqual(33);
expect(xfltr.allSelectedLabels()).toEqual(
Int32Array.from(
obsNGenes.reduce((acc, val, idx) => {
const louvain = obsLouvain[idx];
if (
val >= 0 &&
val < 500 &&
(louvain === "NK cells" || louvain === "B cells")
)
acc.push(idx);
return acc;
}, [])
)
);
xfltr = await xfltr.selectAll();
expect(xfltr.countSelected()).toEqual(annoMatrix.nObs);
});
test("join column select", async () => {
const varIndex = annoMatrix.schema.annotations.var.index;
const { nObs } = annoMatrix.schema.dataframe;
fetch.once(
serverMocks.dataframeResponse(
["TEST"],
[rangeFill(new Float32Array(nObs), 0, 0.1)]
)
);
const xfltr = await crossfilter.select(
"X",
{
where: {
field: "var",
column: varIndex,
value: "TYMP",
},
},
{
mode: "range",
lo: 0,
hi: 50,
inclusive: true,
}
);
expect(xfltr).toBeDefined();
expect(xfltr.countSelected()).toEqual(501);
const df = await annoMatrix.fetch("X", {
where: {
field: "var",
column: varIndex,
value: "TYMP",
},
});
const values = df.icol(0).asArray();
const selected = xfltr.allSelectedMask();
values.every((val, idx) => !(val >= 0 && val <= 50) !== !selected[idx]);
expect(selected.reduce((acc, val) => (val ? acc + 1 : acc), 0)).toEqual(
xfltr.countSelected()
);
});
test("spatial column select", async () => {
fetch.once(
serverMocks.dataframeResponse(
["umap_0", "umap_1"],
[Float32Array.from(embUmap[0]), Float32Array.from(embUmap[1])]
)
);
const xfltr = await crossfilter.select("emb", "umap", {
mode: "within-rect",
minX: 0,
minY: 0,
maxX: 0.5,
maxY: 0.5,
});
expect(xfltr.countSelected()).toEqual(16);
});
test("select on subset", async () => {
const mask = new Uint8Array(annoMatrix.nObs).fill(0);
for (let i = 0; i < mask.length; i += 2) {
mask[i] = true;
}
const annoMatrixSubset = isubsetMask(annoMatrix, mask);
expect(annoMatrixSubset.nObs).toEqual(Math.floor(annoMatrix.nObs / 2));
let xfltr = new AnnoMatrixObsCrossfilter(annoMatrixSubset);
expect(xfltr.countSelected()).toEqual(annoMatrixSubset.nObs);
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
xfltr = await xfltr.select("obs", "louvain", {
mode: "exact",
values: ["NK cells", "B cells"],
});
expect(xfltr).toBeDefined();
expect(xfltr.countSelected()).toEqual(240);
const df = await annoMatrixSubset.fetch("obs", "louvain");
const values = df.col("louvain").asArray();
const selected = xfltr.allSelectedMask();
values.every(
(val, idx) => !["NK cells", "B cells"].includes(val) !== !selected[idx]
);
});
test("select catches errors", async () => {
await expect(crossfilter.select("NADA", "foo")).rejects.toThrow(
"Unknown field name"
);
await expect(crossfilter.select("var", "foo")).rejects.toThrow(
"unable to obsSelect upon the var dimension"
);
fetch.mockRejectOnce(new Error("unknown column name"));
await expect(crossfilter.select("obs", "foo")).rejects.toThrow(
"unknown column name"
);
});
});
describe("mutate matrix", () => {
/*
test the matrix mutators via crossfilter proxy
*/
async function helperAddTestCol(cf, colName, colSchema = null) {
expect(
cf.annoMatrix.getMatrixColumns("obs").includes(colName)
).toBeFalsy();
if (colSchema === null) {
colSchema = {
name: colName,
type: "categorical",
categories: ["toasty"],
};
}
colSchema.name = colName;
const initValue = colSchema.categories[0];
const xfltr = cf.addObsColumn(colSchema, Array, initValue);
expect(
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
(v) => v.name === colName
)
).toHaveLength(1);
const df = await xfltr.annoMatrix.fetch("obs", colName);
expect(df.hasCol(colName)).toBeTruthy();
return xfltr;
}
test("addObsColumn", async () => {
expect(crossfilter.countSelected()).toBe(annoMatrix.nObs);
expect(
crossfilter.annoMatrix.getMatrixColumns("obs").includes("foo")
).toBeFalsy();
const xfltr = crossfilter.addObsColumn(
{ name: "foo", type: "categorical", categories: ["A"] },
Array,
"A"
);
// check schema updates correctly.
expect(xfltr.countSelected()).toBe(annoMatrix.nObs);
expect(
xfltr.annoMatrix.getMatrixColumns("obs").includes("foo")
).toBeTruthy();
expect(xfltr.annoMatrix.schema.annotations.obsByName.foo).toMatchObject({
name: "foo",
type: "categorical",
});
expect(
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
(v) => v.name === "foo"
)
).toHaveLength(1);
// check data update.
const df = await xfltr.annoMatrix.fetch("obs", "foo");
expect(
df
.col("foo")
.asArray()
.every((v) => v === "A")
).toBeTruthy();
// check that we catch dups
expect(() =>
xfltr.addObsColumn(
{ name: "foo", type: "categorical" },
Array,
"toasty"
)
).toThrow("column already exists");
expect(() =>
xfltr.addObsColumn(
{ name: "louvain", type: "categorical" },
Array,
"toasty"
)
).toThrow("column already exists");
});
test("dropObsColumn", async () => {
let xfltr;
/* check that we catch attempt to drop readonly dimension */
expect(() => crossfilter.dropObsColumn("louvain")).toThrow(
"Unknown or readonly obs column"
);
/* non-existent column */
expect(() => crossfilter.dropObsColumn("does-not-exist")).toThrow(
"Unknown or readonly obs column"
);
// add a column, then drop it.
xfltr = await helperAddTestCol(crossfilter, "foo");
xfltr = xfltr.dropObsColumn("foo");
expect(
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
(v) => v.name === "foo"
)
).toHaveLength(0);
expect(xfltr.annoMatrix.schema.annotations.obsByName.foo).toBeUndefined();
fetch.mockRejectOnce(new Error("unknown column name"));
await expect(xfltr.annoMatrix.fetch("obs", "foo")).rejects.toThrow(
"unknown column name"
);
// now same, but ensure we have built an index before doing the drop
xfltr = await helperAddTestCol(crossfilter, "bar");
xfltr = await xfltr.select("obs", "bar", {
mode: "exact",
values: "whatever",
});
xfltr = xfltr.dropObsColumn("bar");
fetch.mockRejectOnce(new Error("unknown column name"));
await expect(xfltr.select("obs", "bar", { mode: "all" })).rejects.toThrow(
"unknown column name"
);
});
test("renameObsColumn", async () => {
let xfltr;
/* catch attempts to rename non-existent or readonly columns */
expect(() =>
crossfilter.renameObsColumn("does-not-exist", "foo")
).toThrow("Unknown or readonly obs column");
expect(() => crossfilter.renameObsColumn("louvain", "foo")).toThrow(
"Unknown or readonly obs column"
);
// add a column, then rename it.
xfltr = await helperAddTestCol(crossfilter, "foo");
xfltr = xfltr.renameObsColumn("foo", "bar");
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toBeUndefined();
expect(xfltr.annoMatrix.getColumnSchema("obs", "bar")).toMatchObject({
name: "bar",
type: "categorical",
});
fetch.mockRejectOnce(new Error("unknown column name"));
await expect(xfltr.annoMatrix.fetch("obs", "foo")).rejects.toThrow(
"unknown column name"
);
const df = await xfltr.annoMatrix.fetch("obs", "bar");
expect(df.hasCol("bar")).toBeTruthy();
// now same, but ensure we have built an index before doing the rename
xfltr = await helperAddTestCol(crossfilter, "bar");
xfltr = await xfltr.select("obs", "bar", {
mode: "exact",
values: "whatever",
});
xfltr = xfltr.renameObsColumn("bar", "xyz");
fetch.mockRejectOnce(new Error("unknown column name"));
await expect(xfltr.select("obs", "bar", { mode: "all" })).rejects.toThrow(
"unknown column name"
);
await expect(
xfltr.select("obs", "xyz", { mode: "none" })
).resolves.toBeInstanceOf(AnnoMatrixObsCrossfilter);
});
test("addObsAnnoCategory", async () => {
let xfltr;
// catch unknown or readonly columns
expect(() => crossfilter.addObsAnnoCategory("louvain", "mumble")).toThrow(
"Unknown or readonly obs column"
);
expect(() =>
crossfilter.addObsAnnoCategory("undefined-name", "mumble")
).toThrow("Unknown or readonly obs column");
// add a column and then add category to it
xfltr = await helperAddTestCol(crossfilter, "foo", {
name: "foo",
type: "categorical",
categories: ["unassigned"],
});
xfltr = xfltr.addObsAnnoCategory("foo", "a-new-label");
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
name: "foo",
type: "categorical",
categories: expect.arrayContaining(["a-new-label", "unassigned"]),
});
// do it again, dup; should throw
expect(() => xfltr.addObsAnnoCategory("foo", "a-new-label")).toThrow(
"category already exists"
);
// now same, but ensure we have built an index before doing the operation
xfltr = await helperAddTestCol(crossfilter, "bar", {
name: "bar",
type: "categorical",
categories: ["unassigned"],
});
xfltr = await xfltr.select("obs", "bar", {
mode: "exact",
values: "something",
});
xfltr = xfltr.addObsAnnoCategory("bar", "a-new-label");
expect(xfltr.annoMatrix.getColumnSchema("obs", "bar")).toMatchObject({
name: "bar",
type: "categorical",
categories: expect.arrayContaining(["a-new-label", "unassigned"]),
});
});
test("removeObsAnnoCategory", async () => {
let xfltr;
// catch unknown or readonly categories
await expect(() =>
crossfilter.removeObsAnnoCategory("louvain", "mumble", "unassigned")
).rejects.toThrow("Unknown or readonly obs column");
await expect(() =>
crossfilter.removeObsAnnoCategory("undefined-name", "mumble")
).rejects.toThrow("Unknown or readonly obs column");
xfltr = await helperAddTestCol(crossfilter, "foo", {
name: "foo",
type: "categorical",
categories: ["unassigned", "red", "green", "blue"],
});
xfltr = await xfltr.select("obs", "foo", { mode: "all" });
expect(
(await xfltr.annoMatrix.fetch("obs", "foo"))
.col("foo")
.asArray()
.every((v) => v === "unassigned")
).toBeTruthy();
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
name: "foo",
type: "categorical",
categories: expect.arrayContaining([
"unassigned",
"red",
"green",
"blue",
]),
});
// remove an unused category
const xfltr1 = await xfltr.removeObsAnnoCategory("foo", "red", "mumble");
expect(
(await xfltr1.annoMatrix.fetch("obs", "foo"))
.col("foo")
.asArray()
.every((v) => v === "unassigned")
).toBeTruthy();
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
name: "foo",
type: "categorical",
categories: expect.arrayContaining([
"unassigned",
"green",
"blue",
"mumble",
]),
});
// remove a used category
const xfltr2 = await xfltr.removeObsAnnoCategory(
"foo",
"unassigned",
"red"
);
expect(
(await xfltr2.annoMatrix.fetch("obs", "foo"))
.col("foo")
.asArray()
.every((v) => v === "red")
).toBeTruthy();
expect(xfltr2.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
name: "foo",
type: "categorical",
categories: expect.arrayContaining(["green", "blue", "red"]),
});
});
test("setObsColumnValues", async () => {
// catch unknown or readonly categories
await expect(() =>
crossfilter.setObsColumnValues("louvain", [0, 1], "unassigned")
).rejects.toThrow("Unknown or readonly obs column");
await expect(() =>
crossfilter.setObsColumnValues("undefined-name", [0], "mumble")
).rejects.toThrow("Unknown or readonly obs column");
let xfltr = await helperAddTestCol(crossfilter, "foo", {
name: "foo",
type: "categorical",
categories: ["unassigned", "red", "green", "blue"],
});
xfltr = await xfltr.select("obs", "foo", { mode: "all" });
// catch unknown row label
await expect(() =>
xfltr.setObsColumnValues("foo", [-1], "red")
).rejects.toThrow("Unknown row label");
// set a few rows
expect(
(await xfltr.annoMatrix.fetch("obs", "foo"))
.col("foo")
.asArray()
.every((v) => v === "unassigned")
).toBeTruthy();
const xfltr1 = await xfltr.setObsColumnValues("foo", [0, 10], "purple");
expect(
(await xfltr1.annoMatrix.fetch("obs", "foo"))
.col("foo")
.asArray()
.every(
(v, i) =>
v === "unassigned" || (v === "purple" && (i === 0 || i === 10))
)
).toBeTruthy();
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
name: "foo",
type: "categorical",
categories: expect.arrayContaining([
"unassigned",
"red",
"green",
"blue",
"purple",
]),
});
expect(xfltr1.countSelected()).toEqual(xfltr1.annoMatrix.nObs);
const xfltr2 = await xfltr1.select("obs", "foo", {
mode: "exact",
values: ["purple"],
});
expect(xfltr2.countSelected()).toEqual(2);
expect(xfltr2.allSelectedLabels()).toEqual(Int32Array.from([0, 10]));
});
test("resetObsColumnValues", async () => {
// catch unknown or readonly categories
await expect(() =>
crossfilter.resetObsColumnValues("louvain", "red", "blue")
).rejects.toThrow("Unknown or readonly obs column");
await expect(() =>
crossfilter.resetObsColumnValues("undefined-name", "red", "blue")
).rejects.toThrow("Unknown or readonly obs column");
let xfltr = await helperAddTestCol(crossfilter, "foo", {
name: "foo",
type: "categorical",
categories: ["unassigned", "red", "green", "blue"],
});
xfltr = await xfltr.select("obs", "foo", {
mode: "exact",
values: "red",
});
// catch unknown category name label
await expect(() =>
xfltr.resetObsColumnValues("foo", "unknown-label", "red")
).rejects.toThrow("unknown category");
let xfltr1 = await xfltr.setObsColumnValues("foo", [0, 10], "purple");
xfltr1 = await xfltr1.select("obs", "foo", {
mode: "exact",
values: "purple",
});
expect(
(await xfltr1.annoMatrix.fetch("obs", "foo"))
.col("foo")
.asArray()
.filter((v) => v === "purple")
).toHaveLength(2);
xfltr1 = await xfltr1.resetObsColumnValues("foo", "purple", "magenta");
expect(
(await xfltr1.annoMatrix.fetch("obs", "foo"))
.col("foo")
.asArray()
.filter((v) => v === "magenta")
).toHaveLength(2);
expect(
(await xfltr1.annoMatrix.fetch("obs", "foo"))
.col("foo")
.asArray()
.filter((v) => v === "purple")
).toHaveLength(0);
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
name: "foo",
type: "categorical",
categories: expect.arrayContaining([
"unassigned",
"red",
"green",
"blue",
"purple",
"magenta",
]),
});
});
});
describe("edge cases", () => {
test("transition from empty annoMatrix", async () => {
// select before fetch needs to work
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
const xfltr = await crossfilter.select("obs", "louvain", {
mode: "exact",
values: "B cells",
});
expect(fetch.mock.calls).toHaveLength(1);
expect(xfltr.obsCrossfilter.hasDimension("obs/louvain")).toBeTruthy();
expect(xfltr.obsCrossfilter.all()).toBe(xfltr.annoMatrix._cache.obs);
expect(xfltr.countSelected()).toEqual(
obsLouvain.reduce(
(count, v) => (v === "B cells" ? count + 1 : count),
0
)
);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
[
781, 1352, 1131, 960, 522, 782, 783, 790, 533, 550, 1116, 751, 866, 1059, 458,
335, 1424, 1014, 1446, 446, 1020, 417, 878, 789, 510, 824, 1545, 996, 937,
1368, 428, 406, 1020, 786, 1019, 750, 822, 982, 876, 930, 838, 1014, 732, 877,
782, 787, 791, 880, 801, 1215, 343, 1460, 1250, 756, 836, 824, 827, 1238,
1243, 1652, 843, 825, 656, 776, 766, 1465, 790, 871, 803, 965, 800, 876, 690,
988, 906, 741, 620, 867, 916, 969, 803, 732, 555, 790, 862, 900, 674, 397,
663, 563, 786, 859, 568, 412, 1043, 1206, 702, 1263, 929, 1079, 938, 316, 900,
919, 862, 903, 390, 1717, 819, 1877, 660, 791, 478, 769, 481, 819, 866, 600,
1185, 650, 775, 699, 642, 857, 832, 388, 710, 341, 894, 935, 604, 1008, 985,
679, 603, 864, 1031, 887, 603, 610, 1119, 669, 794, 963, 756, 637, 1032, 776,
860, 825, 852, 745, 858, 925, 1228, 806, 715, 668, 779, 1197, 888, 1273, 873,
847, 781, 959, 805, 554, 604, 785, 978, 910, 936, 997, 961, 1314, 799, 1112,
677, 775, 1298, 657, 626, 1313, 467, 936, 977, 780, 1311, 432, 579, 850, 736,
800, 892, 860, 720, 822, 681, 954, 889, 1265, 919, 799, 833, 496, 1476, 848,
869, 1059, 490, 897, 832, 864, 419, 856, 907, 791, 756, 771, 957, 1190, 680,
524, 908, 506, 851, 775, 793, 748, 951, 643, 1277, 828, 480, 969, 1112, 648,
805, 1223, 1023, 669, 489, 390, 350, 1113, 837, 1547, 840, 581, 748, 1861,
735, 488, 1016, 585, 797, 769, 490, 1307, 895, 686, 602, 772, 704, 892, 1169,
1375, 1189, 892, 2455, 355, 1856, 1317, 703, 825, 736, 1997, 892, 1034, 545,
1188, 659, 1056, 819, 979, 632, 598, 690, 310, 803, 743, 560, 1073, 844, 882,
841, 815, 771, 976, 986, 820, 957, 640, 1012, 927, 794, 753, 791, 366, 539,
752, 769, 650, 947, 771, 824, 744, 837, 723, 640, 923, 1174, 1597, 699, 618,
1418, 820, 1047, 981, 866, 527, 762, 717, 860, 603, 828, 476, 1071, 775, 614,
913, 836, 669, 942, 792, 871, 1046, 859, 793, 822, 751, 435, 1142, 781, 718,
471, 1750, 892, 841, 1156, 1031, 912, 873, 824, 1233, 1312, 575, 605, 717,
1019, 1215, 928, 1780, 657, 718, 646, 808, 1120, 750, 390, 1100, 456, 755,
1118, 571, 867, 728, 916, 491, 960, 625, 1090, 772, 968, 480, 810, 725, 1016,
1011, 1075, 808, 672, 950, 862, 766, 963, 507, 570, 678, 768, 1037, 885, 1426,
1496, 587, 879, 924, 783, 696, 1057, 783, 867, 919, 1251, 1023, 727, 645,
1217, 929, 792, 994, 1025, 946, 600, 881, 975, 1609, 758, 772, 682, 998, 979,
1045, 706, 808, 855, 819, 1147, 742, 914, 969, 704, 1398, 581, 809, 921, 805,
542, 888, 519, 1092, 762, 698, 752, 771, 899, 1101, 760, 881, 1124, 809, 445,
1703, 789, 641, 819, 890, 767, 806, 1323, 942, 807, 981, 888, 726, 1190, 826,
661, 713, 816, 822, 806, 864, 464, 664, 931, 860, 674, 803, 464, 788, 1068,
781, 843, 779, 873, 707, 492, 669, 982, 749, 789, 780, 561, 655, 432, 801,
945, 770, 503, 766, 776, 970, 989, 654, 762, 882, 1236, 1499, 626, 1380, 1170,
491, 833, 924, 581, 842, 596, 811, 542, 1572, 758, 854, 274, 1413, 872, 559,
907, 951, 1175, 946, 905, 737, 604, 843, 606, 1079, 668, 785, 726, 978, 941,
994, 655, 1013, 987, 591, 1041, 625, 582, 814, 570, 775, 822, 715, 738, 956,
1178, 743, 1861, 841, 944, 783, 643, 924, 936, 431, 490, 532, 524, 620, 749,
1334, 834, 790, 702, 892, 693, 784, 944, 471, 839, 529, 729, 1084, 802, 886,
815, 856, 746, 1318, 545, 696, 872, 1154, 467, 725, 1027, 479, 728, 926, 1282,
907, 833, 1024, 838, 900, 737, 367, 459, 1030, 1279, 756, 662, 1323, 1003,
359, 770, 813, 634, 924, 1184, 901, 816, 1421, 771, 706, 953, 348, 716, 870,
715, 550, 689, 947, 1157, 690, 383, 374, 882, 697, 246, 833, 1006, 1181, 974,
856, 978, 1551, 965, 907, 565, 417, 907, 927, 966, 658, 727, 743, 381, 385,
905, 645, 1167, 936, 724, 618, 1038, 853, 808, 1403, 762, 741, 687, 932, 1096,
601, 652, 895, 682, 1553, 604, 823, 803, 646, 917, 942, 850, 795, 771, 949,
516, 664, 1001, 637, 619, 907, 961, 812, 793, 1043, 1343, 1326, 981, 675, 937,
631, 1026, 1135, 499, 948, 801, 848, 741, 604, 864, 1076, 1106, 1111, 624,
1008, 908, 815, 346, 1062, 803, 749, 779, 1027, 1032, 1040, 654, 631, 755,
854, 850, 798, 864, 1078, 690, 864, 1523, 838, 966, 389, 1654, 808, 885, 1665,
920, 855, 807, 859, 1276, 987, 1079, 678, 626, 831, 829, 1009, 547, 893, 722,
656, 415, 773, 1262, 1218, 365, 661, 805, 1409, 1094, 779, 898, 830, 1242,
864, 576, 901, 1274, 852, 1006, 719, 763, 683, 957, 831, 724, 354, 763, 910,
749, 626, 870, 795, 1078, 736, 835, 402, 1203, 699, 755, 697, 1229, 637, 666,
846, 1036, 1027, 831, 999, 942, 891, 1007, 712, 990, 725, 745, 921, 333, 1042,
895, 873, 1612, 724, 929, 601, 862, 908, 658, 775, 724, 753, 741, 690, 379,
608, 927, 777, 969, 827, 709, 385, 690, 769, 554, 892, 761, 367, 731, 1103,
944, 832, 675, 652, 418, 727, 745, 872, 1336, 863, 934, 844, 721, 432, 782,
1006, 834, 840, 840, 819, 699, 489, 665, 576, 1291, 1102, 826, 880, 738, 904,
686, 874, 887, 873, 560, 766, 710, 1135, 1054, 805, 724, 973, 1201, 575, 838,
865, 546, 811, 884, 886, 791, 1026, 2000, 644, 763, 969, 800, 359, 624, 993,
800, 1167, 833, 1871, 616, 822, 647, 1000, 618, 734, 618, 1938, 861, 945,
1032, 723, 984, 994, 771, 738, 1583, 1113, 614, 1146, 615, 848, 983, 677, 972,
791, 827, 804, 395, 843, 493, 741, 941, 1659, 742, 1517, 559, 937, 740, 781,
819, 813, 578, 1022, 1191, 824, 1146, 757, 638, 830, 713, 609, 1271, 680, 769,
1119, 731, 804, 781, 916, 735, 835, 1257, 472, 879, 851, 1023, 661, 1008, 748,
845, 393, 675, 843, 876, 939, 932, 760, 735, 1561, 752, 940, 705, 405, 690,
1071, 544, 927, 817, 388, 560, 1322, 640, 886, 1075, 689, 524, 606, 802, 868,
939, 753, 770, 1105, 841, 786, 445, 703, 593, 875, 901, 927, 798, 1221, 415,
1381, 949, 1322, 1169, 745, 727, 799, 490, 767, 943, 808, 926, 664, 569, 843,
727, 1222, 457, 1515, 1138, 1174, 525, 878, 525, 999, 778, 772, 819, 1015,
939, 856, 715, 793, 837, 1193, 764, 834, 677, 625, 420, 837, 874, 679, 811,
546, 787, 587, 821, 669, 813, 780, 649, 924, 1322, 701, 792, 817, 667, 804,
593, 740, 1243, 859, 685, 596, 1193, 859, 775, 947, 689, 907, 734, 621, 336,
922, 802, 812, 941, 943, 868, 947, 767, 701, 692, 423, 775, 500, 1100, 812,
728, 616, 928, 835, 454, 812, 769, 1058, 914, 628, 649, 452, 754, 1327, 776,
670, 1017, 599, 431, 967, 1077, 2033, 731, 533, 926, 725, 559, 870, 964, 1341,
1981, 1103, 326, 428, 808, 821, 554, 596, 680, 1034, 849, 566, 879, 1091, 823,
447, 1688, 868, 1254, 942, 462, 1055, 852, 738, 804, 775, 726, 993, 1462,
1007, 798, 1036, 786, 948, 743, 761, 838, 1040, 859, 867, 1188, 846, 687, 672,
629, 725, 660, 809, 469, 600, 812, 856, 397, 786, 895, 882, 449, 890, 823,
1051, 823, 1055, 741, 999, 1241, 790, 878, 778, 1066, 815, 465, 1079, 743,
1098, 807, 1120, 1025, 805, 676, 828, 763, 997, 852, 866, 1118, 508, 928, 958,
932, 892, 905, 494, 710, 1068, 795, 787, 951, 720, 842, 890, 1355, 1005, 872,
1185, 912, 869, 894, 997, 770, 554, 806, 1426, 1012, 452, 896, 426, 239, 829,
895, 787, 1139, 925, 1015, 1360, 1097, 650, 853, 549, 1052, 307, 1152, 907,
1628, 731, 897, 1749, 762, 712, 1195, 851, 864, 968, 845, 331, 840, 734, 948,
842, 1543, 661, 981, 912, 912, 1063, 683, 823, 996, 695, 1483, 927, 574, 1052,
571, 1028, 1263, 671, 958, 747, 866, 896, 489, 643, 923, 820, 1466, 550, 1112,
1006, 1448, 727, 899, 998, 563, 870, 903, 516, 754, 879, 588, 740, 798, 798,
653, 902, 990, 724, 953, 891, 1437, 653, 714, 956, 877, 1012, 824, 1077, 740,
692, 1063, 771, 808, 1389, 1264, 952, 816, 795, 795, 760, 886, 349, 868, 842,
819, 626, 418, 903, 838, 723, 436, 1112, 724, 1299, 719, 843, 1090, 696, 885,
627, 809, 423, 729, 853, 855, 608, 627, 823, 1063, 575, 743, 1528, 681, 544,
422, 731, 920, 761, 884, 982, 784, 496, 573, 521, 663, 794, 975, 856, 978,
590, 905, 695, 816, 976, 816, 753, 791, 858, 813, 841, 1085, 1692, 716, 955,
1467, 741, 296, 738, 1573, 1119, 918, 283, 703, 842, 1253, 676, 1636, 1273,
380, 799, 1491, 878, 939, 725, 1365, 818, 719, 1343, 905, 837, 803, 990, 1084,
976, 1630, 795, 1408, 771, 650, 779, 648, 817, 1127, 882, 954, 830, 732, 783,
756, 708, 976, 718, 887, 809, 795, 662, 912, 1550, 1509, 1021, 1751, 776, 910,
714, 530, 846, 631, 1152, 1118, 755, 573, 1176, 267, 918, 1132, 849, 938,
1140, 909, 840, 806, 904, 788, 778, 715, 869, 714, 883, 767, 858, 788, 553,
634, 1230, 1131, 849, 811, 827, 1753, 713, 484, 783, 722, 596, 514, 372, 925,
747, 840, 673, 955, 796, 719, 718, 857, 578, 1063, 594, 997, 1268, 341, 388,
753, 834, 593, 1189, 911, 738, 476, 816, 641, 746, 635, 953, 801, 326, 849,
499, 865, 1420, 487, 876, 797, 981, 756, 850, 1097, 998, 829, 1159, 955, 1061,
696, 786, 743, 1211, 893, 491, 744, 1447, 767, 1050, 853, 1118, 1428, 555,
612, 854, 789, 889, 784, 712, 1323, 921, 682, 794, 846, 1012, 839, 807, 587,
881, 781, 1010, 1182, 1149, 812, 607, 1155, 714, 642, 1088, 1291, 1196, 325,
804, 370, 359, 855, 1165, 836, 923, 863, 1284, 1011, 889, 984, 512, 939, 883,
697, 1211, 362, 969, 1135, 1239, 580, 1103, 975, 825, 1170, 921, 640, 1180,
378, 982, 916, 1122, 792, 619, 750, 913, 775, 661, 766, 768, 675, 944, 940,
761, 727, 767, 873, 1043, 850, 995, 680, 595, 700, 753, 736, 891, 685, 780,
986, 989, 830, 810, 685, 784, 642, 742, 961, 906, 829, 621, 822, 717, 1210,
800, 1963, 749, 757, 570, 831, 721, 336, 802, 1001, 886, 631, 759, 631, 550,
984, 767, 835, 777, 639, 860, 1413, 747, 779, 540, 367, 1629, 1380, 689, 1001,
809, 337, 1103, 796, 966, 782, 1018, 642, 967, 436, 826, 779, 1000, 601, 796,
945, 1679, 1123, 596, 995, 720, 588, 759, 452, 780, 836, 515, 846, 392, 283,
710, 1158, 796, 895, 585, 559, 859, 879, 858, 842, 643, 1308, 595, 1181, 909,
710, 821, 817, 841, 1197, 640, 1425, 947, 900, 852, 460, 1100, 824, 780, 932,
542, 1137, 1225, 997, 572, 780, 765, 906, 793, 753, 772, 854, 936, 1048, 819,
645, 619, 314, 726, 737, 1162, 1081, 868, 1032, 913, 476, 490, 799, 1201, 997,
898, 212, 1586, 427, 947, 937, 724, 380, 715, 739, 931, 973, 773, 1497, 906,
798, 953, 471, 821, 806, 714, 828, 727, 773, 976, 856, 727, 761, 1084, 1557,
693, 559, 627, 795, 750, 838, 803, 453, 734, 607, 1029, 805, 669, 505, 858,
832, 1019, 585, 1225, 1287, 903, 752, 2020, 774, 666, 843, 857, 887, 1082,
656, 674, 911, 734, 910, 672, 802, 539, 699, 941, 828, 800, 642, 733, 607,
992, 379, 562, 847, 787, 1461, 732, 941, 785, 696, 795, 809, 828, 651, 882,
972, 1323, 679, 774, 784, 766, 520, 671, 796, 644, 1549, 756, 723, 788, 643,
856, 825, 730, 831, 653, 429, 641, 637, 812, 1527, 859, 972, 744, 869, 508,
624, 923, 976, 801, 1014, 1429, 586, 692, 704, 1176, 806, 883, 1249, 765, 743,
907, 666, 669, 364, 794, 959, 766, 937, 1398, 942, 1469, 905, 812, 572, 1378,
1058, 1215, 697, 531, 676, 1819, 503, 801, 943, 874, 813, 694, 494, 660, 1467,
976, 833, 689, 921, 625, 1428, 817, 909, 956, 765, 1207, 829, 1648, 554, 1500,
953, 647, 537, 786, 814, 761, 862, 838, 1102, 1392, 1042, 372, 971, 1364,
1137, 847, 935, 710, 1070, 914, 855, 759, 654, 981, 1193, 397, 1123, 616, 747,
876, 949, 965, 789, 752, 717, 1198, 952, 625, 784, 914, 659, 913, 863, 959,
1637, 796, 1508, 906, 714, 1195, 867, 819, 375, 656, 1047, 745, 866, 1186,
1669, 539, 942, 839, 927, 796, 734, 831, 967, 620, 814, 605, 1391, 655, 1512,
625, 719, 547, 864, 902, 853, 1143, 990, 858, 604, 1291, 701, 859, 768, 1621,
715, 594, 783, 1608, 927, 740, 805, 705, 491, 514, 768, 880, 993, 356, 748,
993, 801, 843, 1194, 794, 606, 810, 882, 682, 1126, 792, 829, 1657, 786, 610,
664, 790, 852, 678, 589, 930, 690, 840, 647, 622, 427, 856, 522, 1262, 722,
641, 997, 983, 1099, 607, 704, 947, 886, 1062, 659, 995, 492, 754, 676, 837,
532, 598, 1274, 921, 700, 828, 828, 543, 815, 709, 842, 1200, 1126, 1327, 849,
1160, 707, 675, 709, 990, 270, 445, 892, 1040, 832, 815, 437, 1401, 1341, 480,
1517, 704, 622, 823, 731, 781, 849, 1209, 472, 838, 643, 1101, 879, 895, 408,
832, 845, 928, 920, 1269, 652, 1463, 840, 706, 956, 1219, 612, 970, 789, 382,
533, 627, 388, 655, 1103, 782, 1231, 617, 589, 758, 882, 909, 661, 558, 947,
491, 1386, 1481, 823, 1064, 681, 652, 363, 824, 933, 611, 886, 806, 531, 864,
648, 1142, 736, 955, 826, 725, 1174, 841, 1006, 1418, 955, 829, 1038, 632,
750, 846, 1033, 859, 890, 893, 1030, 913, 1385, 750, 416, 703, 522, 856, 914,
742, 381, 1079, 671, 1101, 858, 663, 581, 845, 881, 823, 835, 1290, 598, 1130,
793, 838, 765, 768, 815, 810, 912, 818, 790, 665, 703, 875, 657, 1567, 688,
749, 1076, 782, 543, 1100, 841, 809, 789, 1263, 758, 368, 1022, 730, 1101,
478, 488, 618, 940, 771, 784, 847, 1303, 821, 1111, 752, 1128, 958, 742, 782,
824, 751, 872, 939, 606, 698, 705, 963, 607, 621, 1650, 1093, 545, 670, 617,
723, 1194, 1019, 1291, 758, 855, 1549, 743, 1372, 802, 337, 1121, 1028, 1524,
645, 847, 866, 941, 751, 583, 796, 793, 975, 936, 524, 659, 607, 1433, 562,
696, 927, 517, 719, 599, 639, 977, 1019, 816, 672, 1903, 1162, 964, 936, 947,
989, 790, 902, 982, 673, 856, 629, 692, 843, 940, 795, 780, 821, 471, 702,
631, 1557, 868, 901, 798, 1020, 885, 881, 719, 1043, 1238, 565, 776, 696, 725,
365, 811, 1212, 1178, 1132, 661, 831, 786, 471, 835, 564, 929, 958, 706, 388,
842, 965, 1088, 511, 794, 900, 865, 789, 504, 701, 817, 796, 972, 906, 871,
922, 724, 628, 1479, 533, 1101, 1913, 855, 1266, 884, 817, 619, 591, 685, 887,
1336, 656, 1227, 980, 817, 582, 1370, 460, 638, 471, 650, 414, 907, 1147, 732,
992, 801, 822, 529, 737, 806, 816, 889, 1305, 588, 657, 1154, 713, 326, 1129,
1603, 879, 1156, 642, 285, 825, 823, 719, 1253, 971, 853, 916, 1053, 515,
1017, 953, 832, 645, 667, 1326, 547, 636, 1783, 1211, 788, 807, 1104, 884,
848, 788, 1013, 1003, 916, 818, 828, 882, 959, 395, 368, 787, 929, 1379, 711,
733, 752, 464, 626, 735, 946, 876, 647, 536, 954, 486, 712, 786, 438, 807,
1016, 551, 841, 929, 757, 971, 708, 567, 881, 801, 873, 805, 1359, 866, 945,
1068, 819, 815, 1058, 845, 881, 1051, 1179, 718, 657, 882, 709, 754, 735, 603,
944, 1794, 712, 721, 1097, 813, 788, 917, 656, 1104, 1268, 1239, 862, 739,
858, 1066, 752, 615, 721, 571, 861, 933, 807, 1082, 820, 887, 850, 1567, 803,
1156, 721, 692, 700, 458, 637, 873, 1544, 1155, 1227, 622, 454, 724
]
@@ -0,0 +1,4 @@
export const baseDataURL = "https://a.fake.url/api/v0.2";
export { schema } from "./schema";
export * from "./routes";
@@ -0,0 +1,211 @@
import { schema } from "./schema";
import { Dataframe, KeyIndex } from "../../../../src/util/dataframe";
import { encodeMatrixFBS } from "../../../../src/util/stateManager/matrix";
const indexedSchema = {
obsByName: Object.fromEntries(
schema.schema.annotations.obs.columns.map((v) => [v.name, v]) ?? []
),
varByName: Object.fromEntries(
schema.schema.annotations.var.columns.map((v) => [v.name, v]) ?? []
),
embByName: Object.fromEntries(
schema.schema.layout.obs.map((v) => [v.name, v]) ?? []
),
};
function makeMockColumn(s, length) {
const { type } = s;
switch (type) {
case "int32":
return new Int32Array(length).fill(Math.floor(99 * Math.random()));
case "string":
return new Array(length).fill("test");
case "float32":
return new Float32Array(length).fill(99 * Math.random());
case "boolean":
return new Array(length).fill(false);
case "categorical":
return new Array(length).fill(s.categories[0]);
default:
throw new Error("unkonwn type");
}
}
function getEncodedDataframe(colNames, length, colSchemas) {
const colIndex = new KeyIndex(colNames);
const columns = colSchemas.map((s) => makeMockColumn(s, length));
const df = new Dataframe([length, colNames.length], columns, null, colIndex);
const body = encodeMatrixFBS(df);
return body;
}
export function dataframeResponse(colNames, columns) {
const colIndex = new KeyIndex(colNames);
const df = new Dataframe(
[columns[0].length, colNames.length],
columns,
null,
colIndex
);
const body = encodeMatrixFBS(df);
const headers = new Headers({
"Content-Type": "application/octet-stream",
});
return () => Promise.resolve({ body, init: { status: 200, headers } });
}
function annotationObsResponse(request) {
const url = new URL(request.url);
const params = Array.from(url.searchParams.entries());
const names = params
.filter(([k]) => k === "annotation-name")
.map(([, v]) => v);
if (!names.every((n) => indexedSchema.obsByName[n])) {
return Promise.reject(new Error("bad obs annotation name in URL"));
}
const colSchemas = names.map((n) => indexedSchema.obsByName[n]);
const body = getEncodedDataframe(
names,
schema.schema.dataframe.nObs,
colSchemas
);
const headers = new Headers({
"Content-Type": "application/octet-stream",
});
return Promise.resolve({
body,
init: { status: 200, headers },
});
}
function annotationVarResponse(request) {
const url = new URL(request.url);
const params = Array.from(url.searchParams.entries());
const names = params
.filter(([k]) => k === "annotation-name")
.map(([, v]) => v);
if (!names.every((n) => indexedSchema.varByName[n])) {
return Promise.reject(new Error("bad var annotation name in URL"));
}
const colSchemas = names.map((n) => indexedSchema.varByName[n]);
const body = getEncodedDataframe(
names,
schema.schema.dataframe.nVar,
colSchemas
);
const headers = new Headers({
"Content-Type": "application/octet-stream",
});
return Promise.resolve({
body,
init: { status: 200, headers },
});
}
function layoutObsResponse(request) {
const url = new URL(request.url);
const params = Array.from(url.searchParams.entries());
const names = params.filter(([k]) => k === "layout-name").map(([, v]) => v);
if (!names.every((n) => indexedSchema.embByName[n])) {
return Promise.reject(new Error("bad layout name in URL"));
}
const dims = names.map((n) => indexedSchema.embByName[n].dims).flat();
const colSchemas = names
.map((n) => [indexedSchema.embByName[n], indexedSchema.embByName[n]])
.flat();
const body = getEncodedDataframe(
dims,
schema.schema.dataframe.nObs,
colSchemas
);
const headers = new Headers({
"Content-Type": "application/octet-stream",
});
return Promise.resolve({
body,
init: { status: 200, headers },
});
}
function dataVarResponse(request) {
const url = new URL(request.url);
const params = Array.from(url.searchParams.entries());
const colNames = params.map((v) => `${v[0]}/${v[1]}`);
const colSchemas = colNames.map(() => schema.schema.dataframe);
const body = getEncodedDataframe(
colNames,
schema.schema.dataframe.nObs,
colSchemas
);
const headers = new Headers({
"Content-Type": "application/octet-stream",
});
return Promise.resolve({
body,
init: { status: 200, headers },
});
}
export function responder(request) {
const url = new URL(request.url);
const { pathname } = url;
if (pathname.endsWith("/annotations/obs")) {
return annotationObsResponse(request);
}
if (pathname.endsWith("/annotations/var")) {
return annotationVarResponse(request);
}
if (pathname.endsWith("/layout/obs")) {
return layoutObsResponse(request);
}
if (pathname.endsWith("/data/var")) {
return dataVarResponse(request);
}
return Promise.reject(new Error("bad URL"));
}
export function withExpected(expectedURL, expectedParams) {
/*
Do some additional error checking
*/
return (request) => {
// if URL is bogus, reject the promise
const url = new URL(request.url);
if (!url.pathname.endsWith(expectedURL)) {
return Promise.reject(new Error("Unexpected URL!"));
}
const params = Array.from(url.searchParams.entries()).sort(
(a, b) => a[0] < b[0]
);
expectedParams = expectedParams.slice().sort((a, b) => a[0] < b[0]);
if (
params.length !== expectedParams.length ||
!params.every(
(p, i) => p[0] === expectedParams[i][0] && p[1] === expectedParams[i][1]
)
) {
return Promise.reject(new Error("unexpected name requested in URL"));
}
return responder(request);
};
}
export function annotationsObs(names) {
return withExpected(
"/annotations/obs",
names.map((name) => ["annotation-name", name])
);
}
@@ -0,0 +1,80 @@
export const schema = {
schema: {
annotations: {
obs: {
columns: [
{
name: "name_0",
type: "string",
writable: false,
},
{
name: "n_genes",
type: "int32",
writable: false,
},
{
name: "percent_mito",
type: "float32",
writable: false,
},
{
name: "n_counts",
type: "float32",
writable: false,
},
{
name: "louvain",
type: "string",
writable: false,
},
],
index: "name_0",
},
var: {
columns: [
{
name: "name_0",
type: "string",
writable: false,
},
{
name: "n_cells",
type: "int32",
writable: false,
},
],
index: "name_0",
},
},
dataframe: {
nObs: 2638,
nVar: 1838,
type: "float32",
},
layout: {
obs: [
{
dims: ["draw_graph_fr_0", "draw_graph_fr_1"],
name: "draw_graph_fr",
type: "float32",
},
{
dims: ["pca_0", "pca_1"],
name: "pca",
type: "float32",
},
{
dims: ["tsne_0", "tsne_1"],
name: "tsne",
type: "float32",
},
{
dims: ["umap_0", "umap_1"],
name: "umap",
type: "float32",
},
],
},
},
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
import sha1 from "sha1";
import {
_whereCacheGet,
_whereCacheCreate,
_whereCacheMerge,
} from "../../../src/annoMatrix/whereCache";
const schema = {};
describe("whereCache", () => {
test("whereCacheGet - where query, missing cache values", () => {
expect(
_whereCacheGet({}, schema, "X", {
where: {
field: "var",
column: "foo",
value: "bar",
},
})
).toEqual([undefined]);
expect(
_whereCacheGet({}, schema, "X", {
summarize: {
field: "var",
column: "foo",
values: ["bar"],
},
})
).toEqual([undefined]);
expect(
_whereCacheGet({ where: { X: {} } }, schema, "X", {
where: {
field: "var",
column: "foo",
value: "bar",
},
})
).toEqual([undefined]);
expect(
_whereCacheGet({ where: { X: { var: new Map() } } }, schema, "X", {
where: {
field: "var",
column: "foo",
value: "bar",
},
})
).toEqual([undefined]);
expect(
_whereCacheGet(
{ where: { X: { var: new Map([["foo", new Map()]]) } } },
schema,
"X",
{
where: {
field: "var",
column: "foo",
value: "bar",
},
}
)
).toEqual([undefined]);
});
test("whereCacheGet - summarize query, missing cache values", () => {
expect(
_whereCacheGet({}, schema, "X", {
summarize: {
method: "mean",
field: "var",
column: "foo",
values: ["bar"],
},
})
).toEqual([undefined]);
expect(
_whereCacheGet(
{ summarize: { X: { mean: { var: new Map() } } } },
schema,
"X",
{
summarize: {
method: "mean",
field: "var",
column: "foo",
values: ["bar"],
},
}
)
).toEqual([undefined]);
});
test("whereCacheGet - varied lookups", () => {
const whereCache = {
where: {
X: {
var: new Map([
[
"foo",
new Map([
["bar", [0]],
["baz", [1, 2]],
]),
],
]),
},
},
summarize: {
X: {
mean: {
var: new Map([
[
"foo",
new Map([
[sha1("bar"), [0]],
[sha1("baz"), [1, 2]],
]),
],
]),
},
},
},
};
expect(
_whereCacheGet(whereCache, schema, "X", {
where: {
field: "var",
column: "foo",
value: "bar",
},
})
).toEqual([0]);
expect(
_whereCacheGet(whereCache, schema, "X", {
summarize: {
method: "mean",
field: "var",
column: "foo",
values: ["bar"],
},
})
).toEqual([0]);
expect(
_whereCacheGet(whereCache, schema, "X", {
where: {
field: "var",
column: "foo",
value: "baz",
},
})
).toEqual([1, 2]);
expect(
_whereCacheGet(whereCache, schema, "X", {
summarize: {
method: "mean",
field: "var",
column: "foo",
values: ["baz"],
},
})
).toEqual([1, 2]);
expect(_whereCacheGet(whereCache, schema, "Y", {})).toEqual([undefined]);
expect(
_whereCacheGet(whereCache, schema, "X", {
where: {
field: "whoknows",
column: "whatever",
value: "snork",
},
})
).toEqual([undefined]);
expect(
_whereCacheGet(whereCache, schema, "X", {
where: {
field: "var",
column: "whatever",
value: "snork",
},
})
).toEqual([undefined]);
expect(
_whereCacheGet(whereCache, schema, "X", {
where: {
field: "var",
column: "foo",
value: "snork",
},
})
).toEqual([undefined]);
});
test("whereCacheCreate, where query", () => {
const query = {
where: {
field: "queryField",
column: "queryColumn",
value: "queryValue",
},
};
const wc = _whereCacheCreate(
"field",
{
where: {
field: "queryField",
column: "queryColumn",
value: "queryValue",
},
},
[0, 1, 2]
);
expect(wc).toBeDefined();
expect(wc).toEqual(
expect.objectContaining({
where: {
field: {
queryField: expect.any(Map),
},
},
})
);
expect(wc.where.field.queryField.has("queryColumn")).toEqual(true);
expect(wc.where.field.queryField.get("queryColumn")).toBeInstanceOf(Map);
expect(
wc.where.field.queryField.get("queryColumn").has("queryValue")
).toEqual(true);
expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]);
});
test("whereCacheCreate, summarize query", () => {
const query = {
summarize: {
method: "method",
field: "queryField",
column: "queryColumn",
values: ["queryValue"],
},
};
const wc = _whereCacheCreate("field", query, [0, 1, 2]);
expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]);
});
test("whereCacheCreate, unknown query type", () => {
expect(_whereCacheCreate("field", { foobar: true }, [1])).toEqual({});
});
test("whereCacheMerge, where queries", () => {
let wc;
// remember, will mutate dst
const src = _whereCacheCreate(
"field",
{ where: { field: "queryField", column: "queryColumn", value: "foo" } },
["foo"]
);
const dst1 = _whereCacheCreate(
"field",
{ where: { field: "queryField", column: "queryColumn", value: "bar" } },
["dst1"]
);
wc = _whereCacheMerge(dst1, src);
expect(
_whereCacheGet(wc, schema, "field", {
where: {
field: "queryField",
column: "queryColumn",
value: "foo",
},
})
).toEqual(["foo"]);
expect(
_whereCacheGet(wc, schema, "field", {
where: {
field: "queryField",
column: "queryColumn",
value: "bar",
},
})
).toEqual(["dst1"]);
const dst2 = _whereCacheCreate(
"field",
{ where: { field: "queryField", column: "queryColumn", value: "bar" } },
["dst2"]
);
wc = _whereCacheMerge(dst2, dst1, src);
expect(
_whereCacheGet(wc, schema, "field", {
where: {
field: "queryField",
column: "queryColumn",
value: "foo",
},
})
).toEqual(["foo"]);
expect(
_whereCacheGet(wc, schema, "field", {
where: {
field: "queryField",
column: "queryColumn",
value: "bar",
},
})
).toEqual(["dst1"]);
wc = _whereCacheMerge({}, src);
expect(wc).toEqual(src);
wc = _whereCacheMerge({ where: { field: { queryField: new Map() } } }, src);
expect(wc).toEqual(src);
});
test("whereCacheMerge, mixed queries", () => {
const wc = _whereCacheMerge(
_whereCacheCreate(
"field",
{
where: {
field: "queryField",
column: "queryColumn",
value: "foo",
},
},
["a"]
),
_whereCacheCreate(
"field",
{
summarize: {
method: "mean",
field: "queryField",
column: "queryColumn",
values: ["foo", "bar", "baz"],
},
},
["b"]
)
);
expect(
_whereCacheGet(wc, schema, "field", {
where: {
field: "queryField",
column: "queryColumn",
value: "foo",
},
})
).toEqual(["a"]);
expect(
_whereCacheGet(wc, schema, "field", {
summarize: {
method: "mean",
field: "queryField",
column: "queryColumn",
values: ["foo", "bar", "baz"],
},
})
).toEqual(["b"]);
expect(
_whereCacheGet(wc, schema, "field", {
where: {
field: "queryField",
column: "queryColumn",
value: "does-not-exist",
},
})
).toEqual([undefined]);
expect(
_whereCacheGet(wc, schema, "field", {
summarize: {
method: "no-such-method",
field: "queryField",
column: "queryColumn",
values: ["does-not-exist"],
},
})
).toEqual([undefined]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import cloneDeep from "lodash.clonedeep";
import calcCentroid from "../../src/util/centroid";
import quantile from "../../src/util/quantile";
import { matrixFBSToDataframe } from "../../src/util/stateManager/matrix";
import * as REST from "./stateManager/sampleResponses";
import { indexEntireSchema } from "../../src/util/stateManager/schemaHelpers";
import { normalizeWritableCategoricalSchema } from "../../src/annoMatrix/normalize";
describe("centroid", () => {
let schema;
let obsAnnotations;
let obsLayout;
beforeAll(() => {
schema = indexEntireSchema(cloneDeep(REST.schema.schema));
obsAnnotations = matrixFBSToDataframe(REST.annotationsObs);
obsLayout = matrixFBSToDataframe(REST.layoutObs);
normalizeWritableCategoricalSchema(
schema.annotations.obsByName.field3,
obsAnnotations.col("field3")
);
});
test("field4 (categorical obsAnnotation)", () => {
const centroidResult = calcCentroid(
schema,
"field4",
obsAnnotations,
{ current: "umap", currentDimNames: ["umap_0", "umap_1"] },
obsLayout
);
// Check to see that a centroid has been calculated for every categorical value
const keysAsArray = Array.from(centroidResult.keys());
expect(keysAsArray).toEqual(
expect.arrayContaining([83, true, "foo", 2.222222])
);
// This expected result assumes that all cells belong in all categorical values inside of sample response
const expectedResult = [
quantile([0.5], obsLayout.col("umap_0").asArray())[0],
quantile([0.5], obsLayout.col("umap_1").asArray())[0],
];
centroidResult.forEach((coordinate) => {
expect(coordinate).toEqual(expectedResult);
});
});
test("field3 (boolean obsAnnotation)", () => {
const centroidResult = calcCentroid(
schema,
"field3",
obsAnnotations,
{ current: "umap", currentDimNames: ["umap_0", "umap_1"] },
obsLayout
);
// Check to see that a centroid has been calculated for every categorical value
const keysAsArray = Array.from(centroidResult.keys());
expect(keysAsArray).toEqual(expect.arrayContaining([false, true]));
// This expected result assumes that all cells belong in all categorical values inside of sample response
const expectedResult = [
quantile([0.5], obsLayout.col("umap_0").asArray())[0],
quantile([0.5], obsLayout.col("umap_1").asArray())[0],
];
centroidResult.forEach((coordinate) => {
expect(coordinate).toEqual(expectedResult);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ describe("Dataframe column histogram", () => {
new Map([
["n1", new Map([["c1", 1]])],
["n2", new Map([["c2", 1]])],
["n3", new Map([["c3", 1]])]
["n3", new Map([["c3", 1]])],
])
);
// memoized?
@@ -31,7 +31,11 @@ describe("Dataframe column histogram", () => {
const h1 = df.col("value").histogram(3, [0, 2], df.col("name"));
expect(h1).toMatchObject(
new Map([["n1", [1, 0, 0]], ["n2", [0, 1, 0]], ["n3", [0, 0, 1]]])
new Map([
["n1", [1, 0, 0]],
["n2", [0, 1, 0]],
["n3", [0, 0, 1]],
])
);
// memoized?
expect(df.col("value").histogram(3, [0, 2], df.col("name"))).toMatchObject(
@@ -48,7 +52,13 @@ describe("Dataframe column histogram", () => {
);
const h1 = df.col("cat").histogram();
expect(h1).toMatchObject(new Map([["c1", 1], ["c2", 1], ["c3", 1]]));
expect(h1).toMatchObject(
new Map([
["c1", 1],
["c2", 1],
["c3", 1],
])
);
// memoized?
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
});
@@ -78,16 +88,7 @@ describe("Dataframe column histogram", () => {
expect(df.col(1).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
expect(df.col(0).histogram(2, [0, 10])).toEqual([2, 2]);
expect(df.col(0).histogram(10, [0, 100])).toEqual([
3,
2,
1,
0,
0,
0,
0,
0,
0,
2
3, 2, 1, 0, 0, 0, 0, 0, 0, 2,
]);
});
});
@@ -13,7 +13,7 @@ describe("Dataframe column summary", () => {
categorical: true,
categories: [],
categoryCounts: new Map(),
numCategories: 0
numCategories: 0,
})
);
});
@@ -27,7 +27,7 @@ describe("Dataframe column summary", () => {
[true],
new Float32Array([39.3]),
new Int32Array([99]),
[1]
[1],
],
null,
new Dataframe.KeyIndex([
@@ -36,7 +36,7 @@ describe("Dataframe column summary", () => {
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
"nameCategorical",
])
);
@@ -45,7 +45,7 @@ describe("Dataframe column summary", () => {
categorical: true,
categories: ["n1"],
categoryCounts: new Map([["n1", 1]]),
numCategories: 1
numCategories: 1,
})
);
expect(df.icol(1).summarize()).toEqual(
@@ -53,7 +53,7 @@ describe("Dataframe column summary", () => {
categorical: true,
categories: ["hi"],
categoryCounts: new Map([["hi", 1]]),
numCategories: 1
numCategories: 1,
})
);
expect(df.icol(2).summarize()).toEqual(
@@ -61,7 +61,7 @@ describe("Dataframe column summary", () => {
categorical: true,
categories: [true],
categoryCounts: new Map([[true, 1]]),
numCategories: 1
numCategories: 1,
})
);
expect(df.icol(3).summarize()).toEqual(
@@ -71,7 +71,7 @@ describe("Dataframe column summary", () => {
max: float32Conversion(39.3),
nan: 0,
ninf: 0,
pinf: 0
pinf: 0,
})
);
expect(df.icol(4).summarize()).toEqual(
@@ -81,7 +81,7 @@ describe("Dataframe column summary", () => {
max: 99,
nan: 0,
ninf: 0,
pinf: 0
pinf: 0,
})
);
expect(df.icol(5).summarize()).toEqual(
@@ -89,7 +89,7 @@ describe("Dataframe column summary", () => {
categorical: true,
categories: [1],
categoryCounts: new Map([[1, 1]]),
numCategories: 1
numCategories: 1,
})
);
});
@@ -103,7 +103,7 @@ describe("Dataframe column summary", () => {
[false, true, true],
new Float32Array([39.3, 39.3, 0]),
new Int32Array([99, 99, 99]),
[1, false, "0"]
[1, false, "0"],
],
null,
new Dataframe.KeyIndex([
@@ -112,7 +112,7 @@ describe("Dataframe column summary", () => {
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
"nameCategorical",
])
);
@@ -120,24 +120,34 @@ describe("Dataframe column summary", () => {
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["n0", "n1", "n2"]),
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 1]]),
numCategories: 3
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
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
categoryCounts: new Map([
[true, 2],
[false, 1],
]),
numCategories: 2,
})
);
expect(df.icol(3).summarize()).toEqual(
@@ -147,7 +157,7 @@ describe("Dataframe column summary", () => {
max: float32Conversion(39.3),
nan: 0,
ninf: 0,
pinf: 0
pinf: 0,
})
);
expect(df.icol(4).summarize()).toEqual(
@@ -157,15 +167,19 @@ describe("Dataframe column summary", () => {
max: 99,
nan: 0,
ninf: 0,
pinf: 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
categoryCounts: new Map([
[1, 1],
[false, 1],
["0", 1],
]),
numCategories: 3,
})
);
});
@@ -181,10 +195,10 @@ describe("Dataframe column summary", () => {
39.3,
Number.NEGATIVE_INFINITY,
Number.NaN,
Number.POSITIVE_INFINITY
Number.POSITIVE_INFINITY,
]),
new Int32Array([99, 99, 99, 99]),
[1, false, "0", "0"]
[1, false, "0", "0"],
],
null,
new Dataframe.KeyIndex([
@@ -193,7 +207,7 @@ describe("Dataframe column summary", () => {
"nameBoolean",
"nameFloat32",
"nameInt32",
"nameCategorical"
"nameCategorical",
])
);
@@ -201,24 +215,34 @@ describe("Dataframe column summary", () => {
expect.objectContaining({
categorical: true,
categories: expect.arrayContaining(["n0", "n1", "n2"]),
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 2]]),
numCategories: 3
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
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
categoryCounts: new Map([
[true, 2],
[false, 1],
]),
numCategories: 2,
})
);
expect(df.icol(3).summarize()).toEqual(
@@ -228,7 +252,7 @@ describe("Dataframe column summary", () => {
max: float32Conversion(39.3),
nan: 1,
ninf: 1,
pinf: 1
pinf: 1,
})
);
expect(df.icol(4).summarize()).toEqual(
@@ -238,15 +262,19 @@ describe("Dataframe column summary", () => {
max: 99,
nan: 0,
ninf: 0,
pinf: 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
categoryCounts: new Map([
[1, 1],
[false, 1],
["0", 1],
]),
numCategories: 3,
})
);
});
+8 -8
View File
@@ -6,7 +6,7 @@ import {
obsAnnoDimensionName,
diffexpDimensionName,
userDefinedDimensionName,
makeContinuousDimensionName
makeContinuousDimensionName,
} from "../../src/util/nameCreators";
describe("nameCreators", () => {
@@ -14,25 +14,25 @@ describe("nameCreators", () => {
layoutDimensionName,
obsAnnoDimensionName,
diffexpDimensionName,
userDefinedDimensionName
userDefinedDimensionName,
];
test("check for namespace isolation", () => {
const foo = "foo";
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(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) => 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)));
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/));
nameCreators.forEach((fn) => expect(fn("X")).toMatch(/X/));
});
});
@@ -0,0 +1,96 @@
import PromiseLimit from "../../src/util/promiseLimit";
import { range } from "../../src/util/range";
const delay = (t) => new Promise((resolve) => setTimeout(resolve, t));
describe("PromiseLimit", () => {
test("simple evaluation, concurrency 1", async () => {
const plimit = new PromiseLimit(1);
const result = await Promise.all([
plimit.add(() => Promise.resolve(1)),
plimit.add(() => Promise.resolve(2)),
plimit.add(() => Promise.resolve(3)),
plimit.add(() => Promise.resolve(4)),
]);
expect(result).toEqual([1, 2, 3, 4]);
});
test("simple evaluation, concurrency > 1", async () => {
const plimit = new PromiseLimit(100);
const result = await Promise.all([
plimit.add(() => Promise.resolve(1)),
plimit.add(() => Promise.resolve(2)),
plimit.add(() => Promise.resolve(3)),
plimit.add(() => Promise.resolve(4)),
]);
expect(result).toEqual([1, 2, 3, 4]);
});
test("eval in order of insertion", async () => {
const plimit = new PromiseLimit(100);
const result = await Promise.all([
plimit.add(() => Promise.resolve(1)),
plimit.add(() => Promise.resolve(2)),
plimit.add(() => Promise.resolve(3)),
plimit.add(() => Promise.resolve(4)),
]);
expect(result).toEqual([1, 2, 3, 4]);
});
test("obeys concurrency limit", async () => {
const plimit = new PromiseLimit(2);
let running = 0;
let maxRunning = 0;
const callback = async () => {
running += 1;
maxRunning = running > maxRunning ? running : maxRunning;
await delay(100);
running -= 1;
};
await Promise.all(range(10).map((i) => plimit.add(() => callback(i))));
expect(maxRunning).toEqual(2);
});
test("rejection", async () => {
const plimit = new PromiseLimit(2);
const result = await Promise.all([
plimit.add(() => Promise.resolve("OK")),
// eslint-disable-next-line prefer-promise-reject-errors -- unit test
plimit.add(() => Promise.reject("not OK")).catch((e) => e),
plimit.add(() => Promise.resolve("OK")),
plimit
.add(() => {
throw new Error("not OK");
})
.catch((e) => e.message),
]);
expect(result).toEqual(["OK", "not OK", "OK", "not OK"]);
});
test("priority queue", async () => {
const plimit = new PromiseLimit(1);
let finishOrder = 0;
const callback = () => async () => {
await delay(100);
const result = finishOrder;
finishOrder += 1;
return result;
};
const result = await Promise.all([
plimit.add(callback()),
plimit.priorityAdd(4, callback()),
plimit.priorityAdd(0, callback()),
plimit.priorityAdd(1, callback()),
plimit.priorityAdd(-1, callback()),
]);
expect(result).toEqual([0, 4, 2, 3, 1]);
});
});
+20 -24
View File
@@ -1,29 +1,25 @@
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("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
]);
});
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,
]);
});
});
+35 -37
View File
@@ -1,50 +1,48 @@
import { range, rangeFill, linspace } from "../../src/util/range";
describe("range", () => {
test("no defaults", () => {
expect(range(0, 3, 1)).toMatchObject([0, 1, 2]);
});
test("no defaults", () => {
expect(range(0, 3, 1)).toMatchObject([0, 1, 2]);
});
test("range(stop)", () => {
expect(range(3)).toMatchObject([0, 1, 2]);
expect(range(0)).toMatchObject([]);
expect(range(1)).toMatchObject([0]);
});
test("range(stop)", () => {
expect(range(3)).toMatchObject([0, 1, 2]);
expect(range(0)).toMatchObject([]);
expect(range(1)).toMatchObject([0]);
});
test("range(start,stop)", () => {
expect(range(0, 0)).toMatchObject([]);
expect(range(0, 2)).toMatchObject([0, 1]);
expect(range(4, 8)).toMatchObject([4, 5, 6, 7]);
});
test("range(start,stop)", () => {
expect(range(0, 0)).toMatchObject([]);
expect(range(0, 2)).toMatchObject([0, 1]);
expect(range(4, 8)).toMatchObject([4, 5, 6, 7]);
});
test("range(start, stop, step", () => {
expect(range(4, 0, -1)).toMatchObject([4, 3, 2, 1]);
expect(range(0, 4, 2)).toMatchObject([0, 2]);
});
test("range(start, stop, step", () => {
expect(range(4, 0, -1)).toMatchObject([4, 3, 2, 1]);
expect(range(0, 4, 2)).toMatchObject([0, 2]);
});
});
describe("rangefill", () => {
test("rangeFill(arr)", () => {
expect(rangeFill(new Int32Array(3))).toMatchObject(
new Int32Array([0, 1, 2])
);
});
test("rangeFill(arr, start)", () => {
expect(rangeFill(new Int32Array(2), 1)).toMatchObject(
new Int32Array([1, 2])
);
});
test("rangeFill(arr, start, step)", () => {
expect(rangeFill(new Int32Array(3), 2, -1)).toMatchObject(
new Int32Array([2, 1, 0])
);
});
test("rangeFill(arr)", () => {
expect(rangeFill(new Int32Array(3))).toMatchObject(
new Int32Array([0, 1, 2])
);
});
test("rangeFill(arr, start)", () => {
expect(rangeFill(new Int32Array(2), 1)).toMatchObject(
new Int32Array([1, 2])
);
});
test("rangeFill(arr, start, step)", () => {
expect(rangeFill(new Int32Array(3), 2, -1)).toMatchObject(
new Int32Array([2, 1, 0])
);
});
});
describe("linspace", () => {
test("linspace(arr, start, step)", () => {
expect(linspace(0.0, 2.0, 5)).toMatchObject(
[0.0, 0.5, 1.0, 1.5, 2.0]
);
});
test("linspace(arr, start, step)", () => {
expect(linspace(0.0, 2.0, 5)).toMatchObject([0.0, 0.5, 1.0, 1.5, 2.0]);
});
});
@@ -0,0 +1,198 @@
/* eslint-disable no-bitwise -- unsigned right shift better than Math.round */
/*
test color helpers
*/
import {
createColorTable,
loadUserColorConfig,
} from "../../../src/util/stateManager/colorHelpers";
import * as Dataframe from "../../../src/util/dataframe";
describe("categorical color helpers", () => {
/*
Primary test constraint for categorical colors is that they are ordered/identified
by schema order, NOT by value. Ie,
scale(schemaIndex) should match rgb[obsOffset]
*/
const schema = indexSchema({
annotations: {
obs: {
columns: [
{
name: "name_0",
type: "string",
writable: false,
},
{
name: "continuousColumn",
type: "float32",
writable: false,
},
{
categories: [
"CD4 T cells",
"CD14+ Monocytes",
"B cells",
"CD8 T cells",
"NK cells",
"FCGR3A+ Monocytes",
"Dendritic cells",
"Megakaryocytes",
],
name: "categoricalColumn",
type: "categorical",
writable: false,
},
],
index: "name_0",
},
var: {
columns: [
{
name: "name_0",
type: "string",
writable: false,
},
],
index: "name_0",
},
},
dataframe: {
nObs: 2638,
nVar: 1838,
type: "float32",
},
layout: {},
});
const catColCategories = schema.annotations.obs.columns[2].categories;
const obsDataframe = new Dataframe.Dataframe(
[schema.dataframe.nObs, 2],
[
new Float32Array(schema.dataframe.nObs).map(() => Math.random()),
new Array(schema.dataframe.nObs)
.fill("")
.map(
() =>
catColCategories[(Math.random() * catColCategories.length) >>> 0]
),
],
null,
new Dataframe.KeyIndex(["continuousColumn", "categoricalColumn"])
);
test("default category order", () => {
const ct = createColorTable(
"color by categorical metadata",
"categoricalColumn",
obsDataframe,
schema
);
expect(ct).toBeDefined();
const data = obsDataframe.col("categoricalColumn").asArray();
const cats = schema.annotations.obsByName.categoricalColumn.categories;
for (let i = 0; i < schema.dataframe.nObs; i += 1) {
expect(makeScale(ct.rgb[i])).toEqual(ct.scale(cats.indexOf(data[i])));
}
});
test("shuffle category order", () => {
const schemaClone = indexSchema(JSON.parse(JSON.stringify(schema)));
shuffle(schemaClone.annotations.obsByName.categoricalColumn.categories);
const ct = createColorTable(
"color by categorical metadata",
"categoricalColumn",
obsDataframe,
schemaClone
);
expect(ct).toBeDefined();
const data = obsDataframe.col("categoricalColumn").asArray();
const cats = schemaClone.annotations.obsByName.categoricalColumn.categories;
for (let i = 0; i < schemaClone.dataframe.nObs; i += 1) {
expect(makeScale(ct.rgb[i])).toEqual(ct.scale(cats.indexOf(data[i])));
}
});
test("user defined color order", () => {
const cats = schema.annotations.obsByName.categoricalColumn.categories;
const shuffleCats = shuffle(
Array.from(schema.annotations.obsByName.categoricalColumn.categories)
);
const userDefinedColorTable = {
categoricalColumn: shuffleCats.reduce((acc, label) => {
acc[label] = randRGBColor();
return acc;
}, {}),
};
const userColors = loadUserColorConfig(userDefinedColorTable);
expect(userColors).toBeDefined();
const ct = createColorTable(
"color by categorical metadata",
"categoricalColumn",
obsDataframe,
schema,
userColors
);
expect(ct).toBeDefined();
const data = obsDataframe.col("categoricalColumn").asArray();
for (let i = 0; i < schema.dataframe.nObs; i += 1) {
expect(makeScale(ct.rgb[i])).toEqual(
ct.scale(cats.indexOf(data[i])).toString()
);
}
});
});
/*
TODO:
1. mix up category order in schema to make sure it works with varied order
2. user defined colors
*/
function indexSchema(schema) {
schema.annotations.obsByName = Object.fromEntries(
schema.annotations?.obs?.columns?.map((v) => [v.name, v]) ?? []
);
schema.annotations.varByName = Object.fromEntries(
schema.annotations?.var?.columns?.map((v) => [v.name, v]) ?? []
);
schema.layout.obsByName = Object.fromEntries(
schema.layout?.obs?.map((v) => [v.name, v]) ?? []
);
schema.layout.varByName = Object.fromEntries(
schema.layout?.var?.map((v) => [v.name, v]) ?? []
);
return schema;
}
function makeScale(rgb) {
// make a scale string from a rgb float triple
return `rgb(${(rgb[0] * 255) >>> 0}, ${(rgb[1] * 255) >>> 0}, ${
(rgb[2] * 256) >>> 0
})`;
}
function shuffle(array) {
for (let i = array.length - 1; i > 0; i -= 1) {
const j = (Math.random() * (i + 1)) >>> 0;
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function randHexColor() {
const hex = ((Math.random() * 255) >>> 0).toString(16);
return `0${hex}`.slice(-2);
}
function randRGBColor() {
return `#${randHexColor()}${randHexColor()}${randHexColor()}`;
}
/* eslint-enable no-bitwise -- unsigned right shift better than Math.round */
@@ -0,0 +1,9 @@
/*
test controls helpers
*/
// TODO #2227 test: improve test coverage on control helper functions
// (`isSelectableCategoryName()`, `selectableCategoryNames()`, `createCategorySummaryFromDfCol()`, `createCategoricalSelection()`, )
describe("controls helpers", () => {
test("placeholder", () => {});
});
+23 -23
View File
@@ -3,32 +3,32 @@ test FBS encode/decode API
*/
import { Dataframe, KeyIndex } from "../../../src/util/dataframe";
import {
decodeMatrixFBS,
encodeMatrixFBS
decodeMatrixFBS,
encodeMatrixFBS,
} from "../../../src/util/stateManager/matrix";
describe("encode/decode", () => {
test("round trip", () => {
const columns = [
["red", "green", "blue"],
new Int32Array(3).fill(0),
new Uint32Array(3).fill(1),
new Float32Array(3).fill(2)
];
test("round trip", () => {
const columns = [
["red", "green", "blue"],
new Int32Array(3).fill(0),
new Uint32Array(3).fill(1),
new Float32Array(3).fill(2),
];
const dfNoColIdx = new Dataframe([3, 4], columns);
const dfA = decodeMatrixFBS(encodeMatrixFBS(dfNoColIdx));
expect([dfA.nRows, dfA.nCols]).toEqual(dfNoColIdx.dims);
expect(dfA.colIdx).toBeNull();
expect(dfA.rowIdx).toBeNull();
expect(dfA.columns).toEqual(columns);
const dfNoColIdx = new Dataframe([3, 4], columns);
const dfA = decodeMatrixFBS(encodeMatrixFBS(dfNoColIdx));
expect([dfA.nRows, dfA.nCols]).toEqual(dfNoColIdx.dims);
expect(dfA.colIdx).toBeNull();
expect(dfA.rowIdx).toBeNull();
expect(dfA.columns).toEqual(columns);
const colIndex = new KeyIndex(["a", "b", "c", "d"]);
const dfWithColIdx = new Dataframe([3, 4], columns, null, colIndex);
const dfB = decodeMatrixFBS(encodeMatrixFBS(dfWithColIdx));
expect([dfB.nRows, dfB.nCols]).toEqual(dfWithColIdx.dims);
expect(dfB.colIdx).toEqual(colIndex.keys());
expect(dfB.rowIdx).toBeNull();
expect(dfB.columns).toEqual(columns);
});
const colIndex = new KeyIndex(["a", "b", "c", "d"]);
const dfWithColIdx = new Dataframe([3, 4], columns, null, colIndex);
const dfB = decodeMatrixFBS(encodeMatrixFBS(dfWithColIdx));
expect([dfB.nRows, dfB.nCols]).toEqual(dfWithColIdx.dims);
expect(dfB.colIdx).toEqual(colIndex.labels());
expect(dfB.rowIdx).toBeNull();
expect(dfB.columns).toEqual(columns);
});
});
@@ -1,4 +1,7 @@
/* eslint no-bitwise: "off" */
import every from "lodash.every";
import map from "lodash.map";
import isNumber from "lodash.isnumber";
import zip from "lodash.zip";
import _ from "lodash";
import { flatbuffers } from "flatbuffers";
import { NetEncoding } from "../../../src/util/stateManager/matrix_generated";
@@ -17,13 +20,13 @@ const aConfigResponse = {
{ method: "POST", path: "/cluster/", available: false },
{ method: "POST", path: "/layout/", available: false },
{ method: "POST", path: "/diffexp/", available: false },
{ method: "POST", path: "/saveLocal/", available: false }
{ method: "POST", path: "/saveLocal/", available: false },
],
displayNames: {
engine: "the little engine that could",
dataset: "all your zeros are mine"
}
}
dataset: "all your zeros are mine",
},
},
};
const aSchemaResponse = {
@@ -31,7 +34,7 @@ const aSchemaResponse = {
dataframe: {
nObs,
nVar,
type: "float32"
type: "float32",
},
annotations: {
obs: {
@@ -44,9 +47,9 @@ const aSchemaResponse = {
{
name: "field4",
type: "categorical",
categories: field4Categories
}
]
categories: field4Categories,
},
],
},
var: {
index: "name",
@@ -58,46 +61,48 @@ const aSchemaResponse = {
{
name: "fieldD",
type: "categorical",
categories: fieldDCategories
}
]
}
categories: fieldDCategories,
},
],
},
},
layout: {
obs: [{ name: "umap", type: "float32", dims: ["umap_0", "umap_1"] }],
var: []
}
}
var: [],
},
},
};
const anAnnotationsObsJSONResponse = {
names: ["name", "field1", "field2", "field3", "field4"],
data: _()
.range(nObs)
.map(idx => [
.map((idx) => [
idx,
`obs${idx}`,
2 * idx,
idx + 0.0133,
// eslint-disable-next-line no-bitwise -- idx & 1 to check for odd numbers
!!(idx & 1),
field4Categories[idx % field4Categories.length]
field4Categories[idx % field4Categories.length],
])
.value()
.value(),
};
const anAnnotationsVarJSONResponse = {
names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"],
data: _()
.range(nVar)
.map(idx => [
.map((idx) => [
idx,
10 * idx,
idx + 2.90143,
// eslint-disable-next-line no-bitwise -- idx & 1 to check for odd numbers
!!(idx & 1),
fieldDCategories[idx % fieldDCategories.length],
`var${idx}`
`var${idx}`,
])
.value()
.value(),
};
function encodeTypedArray(builder, uType, uData) {
@@ -112,7 +117,7 @@ function encodeTypedArray(builder, uType, uData) {
function encodeMatrix(columns, colIndex = undefined) {
/*
IMPORTANT: this is not a general purpose encoder. in particular,
it doesn't correctly handle all column index types, nor does it
it doesn't correctly handle all column index types, nor does it
handle all column typedarray types.
encodeMatrixFBS in matrix.py is more general. This is used only
@@ -120,10 +125,10 @@ function encodeMatrix(columns, colIndex = undefined) {
*/
const utf8Encoder = new TextEncoder("utf-8");
const builder = new flatbuffers.Builder(1024);
const cols = _.map(columns, carr => {
const cols = map(columns, (carr) => {
let uType;
let tarr;
if (_.every(carr, _.isNumber)) {
if (every(carr, isNumber)) {
uType = NetEncoding.TypedArray.Float32Array;
tarr = encodeTypedArray(builder, uType, new Float32Array(carr));
} else {
@@ -166,19 +171,19 @@ function encodeMatrix(columns, colIndex = undefined) {
}
const anAnnotationsObsFBSResponse = (() => {
const columns = _.zip(...anAnnotationsObsJSONResponse.data).slice(1);
const columns = zip(...anAnnotationsObsJSONResponse.data).slice(1);
return encodeMatrix(columns, anAnnotationsObsJSONResponse.names);
})();
const anAnnotationsVarFBSResponse = (() => {
const columns = _.zip(...anAnnotationsVarJSONResponse.data).slice(1);
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())
new Float32Array(nObs).fill(Math.random()),
];
return encodeMatrix(coords, ["umap_0", "umap_1"]);
})();
@@ -187,8 +192,8 @@ const aDataObsResponse = {
var: [2, 4, 29],
obs: _()
.range(nObs)
.map(idx => [idx, Math.random(), Math.random(), Math.random()])
.value()
.map((idx) => [idx, Math.random(), Math.random(), Math.random()])
.value(),
};
export {
@@ -197,5 +202,5 @@ export {
anAnnotationsVarFBSResponse as annotationsVar,
anAnnotationsObsFBSResponse as annotationsObs,
aSchemaResponse as schema,
aConfigResponse as config
aConfigResponse as config,
};
@@ -1,68 +0,0 @@
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.columns.length
]);
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
expect(universe.obsLayout.colIndex.keys()).toEqual(
universe.schema.layout.obs[0].dims
);
expect(universe.varAnnotations.dims).toEqual([
nVar,
REST.schema.schema.annotations.var.columns.length
]);
expect(universe.varData.isEmpty()).toBeTruthy();
});
});
@@ -1,190 +0,0 @@
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(
_.cloneDeep(REST.config),
_.cloneDeep(REST.schema),
_.cloneDeep(REST.annotationsObs),
_.cloneDeep(REST.annotationsVar),
_.cloneDeep(REST.layoutObs)
);
/* create world */
const world = World.createWorldFromEntireUniverse(universe);
/* create crossfilter */
const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations),
world,
REST.schema.schema.layout.obs[0].dims
);
return {
universe,
world,
crossfilter
};
};
describe("createWorldFromEntireUniverse", () => {
test("create from REST sample", () => {
const universe = Universe.createUniverseFromResponse(
_.cloneDeep(REST.config),
_.cloneDeep(REST.schema),
_.cloneDeep(REST.annotationsObs),
_.cloneDeep(REST.annotationsVar),
_.cloneDeep(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(
world.schema.layout.obs[0].dims
);
});
});
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.columns,
c => c.name
);
const obsIndexColName = REST.schema.schema.annotations.obs.index;
const schemaByObsName = _.keyBy(
REST.schema.schema.annotations.obs.columns,
"name"
);
expect(crossfilter).toBeDefined();
annotationNames.forEach(name => {
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
if (name === obsIndexColName) {
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);
});
@@ -1,5 +1,3 @@
// jshint esversion: 6
import BitArray from "../../../src/util/typedCrossfilter/bitArray";
const defaultTestLength = 8;
@@ -183,3 +181,24 @@ describe("fillBySelection", () => {
expect(arr).toEqual(truth);
});
});
describe("wide bitarray", () => {
test.each([9, 30, 31, 32, 33, 54, 63, 64, 65, 127, 128, 129])(
"more than %d dimensions",
(ndim) => {
/* ensure we move across the uint boundary correctly */
const ba = new BitArray(defaultTestLength);
expect(ba).toBeDefined();
for (let i = 0; i < ndim; i += 1) {
expect(ba.allocDimension()).toEqual(i);
}
ba.freeDimension(0);
expect(ba.allocDimension()).toEqual(0);
ba.freeDimension(ndim - 1);
expect(ba.allocDimension()).toEqual(ndim - 1);
}
);
});
@@ -1,4 +1,5 @@
import _ from "lodash";
import filter from "lodash.filter";
import zip from "lodash.zip";
import Crossfilter from "../../../src/util/typedCrossfilter";
@@ -11,7 +12,7 @@ const someData = [
type: "tab",
productIDs: ["001"],
coords: [0, 0],
nonFinite: 0.0
nonFinite: 0.0,
},
{
date: "2011-11-14T16:20:19Z",
@@ -21,7 +22,7 @@ const someData = [
type: "tab",
productIDs: ["001", "005"],
coords: [0.4, 0.4],
nonFinite: Number.NaN
nonFinite: Number.NaN,
},
{
date: "2011-11-14T16:28:54Z",
@@ -31,7 +32,7 @@ const someData = [
type: "visa",
productIDs: ["004", "005"],
coords: [0.3, 0.1],
nonFinite: Number.POSITIVE_INFINITY
nonFinite: Number.POSITIVE_INFINITY,
},
{
date: "2011-11-14T16:30:43Z",
@@ -41,7 +42,7 @@ const someData = [
type: "tab",
productIDs: ["001", "002"],
coords: [0.392, 0.1],
nonFinite: Number.NEGATIVE_INFINITY
nonFinite: Number.NEGATIVE_INFINITY,
},
{
date: "2011-11-14T16:48:46Z",
@@ -51,7 +52,7 @@ const someData = [
type: "tab",
productIDs: ["005"],
coords: [0.7, 0.0482],
nonFinite: 1.0
nonFinite: 1.0,
},
{
date: "2011-11-14T16:53:41Z",
@@ -61,7 +62,7 @@ const someData = [
type: "tab",
productIDs: ["001", "004", "005"],
coords: [0.9999, 1.0],
nonFinite: Number.NaN
nonFinite: Number.NaN,
},
{
date: "2011-11-14T16:54:06Z",
@@ -71,7 +72,7 @@ const someData = [
type: "cash",
productIDs: ["001", "002", "003", "004", "005"],
coords: [0.384, 0.6938],
nonFinite: 99.0
nonFinite: 99.0,
},
{
date: "2011-11-14T16:58:03Z",
@@ -81,7 +82,7 @@ const someData = [
type: "tab",
productIDs: ["001"],
coords: [0.4822, 0.482],
nonFinite: Number.NaN
nonFinite: Number.NaN,
},
{
date: "2011-11-14T17:07:21Z",
@@ -91,7 +92,7 @@ const someData = [
type: "tab",
productIDs: ["004", "005"],
coords: [0.2234, 0],
nonFinite: Number.NaN
nonFinite: Number.NaN,
},
{
date: "2011-11-14T17:22:59Z",
@@ -101,7 +102,7 @@ const someData = [
type: "tab",
productIDs: ["001", "002", "004", "005"],
coords: [0.382, 0.38485],
nonFinite: -1
nonFinite: -1,
},
{
date: "2011-11-14T17:25:45Z",
@@ -111,7 +112,7 @@ const someData = [
type: "cash",
productIDs: ["002"],
coords: [0.998, 0.8472],
nonFinite: 0.0
nonFinite: 0.0,
},
{
date: "2011-11-14T17:29:52Z",
@@ -121,8 +122,8 @@ const someData = [
type: "visa",
productIDs: ["004"],
coords: [0.8273, 0.3384],
nonFinite: 0.0
}
nonFinite: 0.0,
},
];
let payments = null;
@@ -248,16 +249,26 @@ describe("ImmutableTypedCrossfilter", () => {
test("none", () => {
expect(p.select("quantity", { mode: "none" }).countSelected()).toEqual(0);
});
test.each([[[]], [[2]], [[2, 1]], [[9, 82]], [[0, 1]]])("exact: %p", v =>
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)
).toEqual(filter(someData, (d) => v.includes(d.quantity)).length)
);
test.each([[0, 1], [1, 2], [0, 99], [99, 100000]])("range %p", (lo, hi) =>
test("single value exact", () => {
expect(
p.select("quantity", { mode: "exact", values: 2 }).countSelected()
).toEqual(filter(someData, (d) => d.quantity === 2).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
filter(someData, (d) => d.quantity >= lo && d.quantity < hi).length
)
);
test("bad mode", () => {
@@ -284,12 +295,17 @@ describe("ImmutableTypedCrossfilter", () => {
[["tab"]],
[["visa"]],
[["visa", "tab"]],
[["cash", "tab", "visa"]]
])("exact: %p", v =>
[["cash", "tab", "visa"]],
])("exact: %p", (v) =>
expect(
p.select("type", { mode: "exact", values: v }).countSelected()
).toEqual(_.filter(someData, d => v.includes(d.type)).length)
).toEqual(filter(someData, (d) => v.includes(d.type)).length)
);
test("single value exact", () => {
expect(
p.select("type", { mode: "exact", values: "tab" }).countSelected()
).toEqual(filter(someData, (d) => d.type === "tab").length);
});
test("range", () => {
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
Error
@@ -303,8 +319,8 @@ describe("ImmutableTypedCrossfilter", () => {
describe("spatial dimension", () => {
let p;
beforeEach(() => {
const X = someData.map(r => r.coords[0]);
const Y = someData.map(r => r.coords[1]);
const X = someData.map((r) => r.coords[0]);
const Y = someData.map((r) => r.coords[1]);
p = payments.addDimension("coords", "spatial", X, Y);
});
@@ -316,34 +332,76 @@ describe("ImmutableTypedCrossfilter", () => {
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, 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]],
[true, true, true, true, true, false, true, true, true, true, true, true]
[
[0, 0],
[0, 1],
[1, 1],
[1, 0],
],
[
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true,
],
],
[
[[0, 0], [0, 0.5], [0.5, 0.5], [0.5, 0]],
[true, true, true, true, false, false, false, true, true, true, false, false]
]
[
[0, 0],
[0, 0.5],
[0.5, 0.5],
[0.5, 0],
],
[
true,
true,
true,
true,
false,
false,
false,
true,
true,
true,
false,
false,
],
],
])("within-polygon %p", (polygon, expected) => {
expect(p.select("coords", { mode: "within-polygon", polygon }).allSelected())
.toEqual(_.zip(someData, expected).filter(x => x[1]).map(x => x[0]));
expect(
p.select("coords", { mode: "within-polygon", polygon }).allSelected()
).toEqual(
zip(someData, expected)
.filter((x) => x[1])
.map((x) => x[0])
);
});
});
@@ -381,7 +439,7 @@ describe("ImmutableTypedCrossfilter", () => {
p
.select("nonFinite", {
mode: "exact",
values: [Number.POSITIVE_INFINITY]
values: [Number.POSITIVE_INFINITY],
})
.countSelected()
).toEqual(1);
@@ -389,7 +447,7 @@ describe("ImmutableTypedCrossfilter", () => {
p
.select("nonFinite", {
mode: "exact",
values: [Number.NEGATIVE_INFINITY]
values: [Number.NEGATIVE_INFINITY],
})
.countSelected()
).toEqual(1);
@@ -402,7 +460,7 @@ describe("ImmutableTypedCrossfilter", () => {
p
.select("nonFinite", {
mode: "exact",
values: [Number.POSITIVE_INFINITY, 0, 1, 99]
values: [Number.POSITIVE_INFINITY, 0, 1, 99],
})
.countSelected()
).toEqual(6);
@@ -414,7 +472,7 @@ describe("ImmutableTypedCrossfilter", () => {
.select("nonFinite", {
mode: "range",
lo: 0,
hi: Number.POSITIVE_INFINITY
hi: Number.POSITIVE_INFINITY,
})
.countSelected()
).toEqual(5);
@@ -423,7 +481,7 @@ describe("ImmutableTypedCrossfilter", () => {
.select("nonFinite", {
mode: "range",
lo: 0,
hi: Number.NaN
hi: Number.NaN,
})
.countSelected()
).toEqual(6);
@@ -432,7 +490,7 @@ describe("ImmutableTypedCrossfilter", () => {
.select("nonFinite", {
mode: "range",
lo: Number.NEGATIVE_INFINITY,
hi: Number.POSITIVE_INFINITY
hi: Number.POSITIVE_INFINITY,
})
.countSelected()
).toEqual(7);
@@ -1,5 +1,3 @@
// jshint esversion: 6
// const PositiveIntervals = require("../../src/util/typedCrossfilter/positiveIntervals");
import PositiveIntervals from "../../../src/util/typedCrossfilter/positiveIntervals";
@@ -10,15 +8,30 @@ describe("canonicalize", () => {
test("simple, already correct", () => {
expect(PositiveIntervals.canonicalize([[0, 1]])).toEqual([[0, 1]]);
expect(PositiveIntervals.canonicalize([[0, 1], [2, 3]])).toEqual([
expect(
PositiveIntervals.canonicalize([
[0, 1],
[2, 3],
])
).toEqual([
[0, 1],
[2, 3]
[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]]);
expect(
PositiveIntervals.canonicalize([
[0, 1],
[1, 2],
])
).toEqual([[0, 2]]);
expect(
PositiveIntervals.canonicalize([
[1, 2],
[2, 3],
])
).toEqual([[1, 3]]);
});
});
@@ -26,14 +39,30 @@ 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([
expect(
PositiveIntervals.union(
[],
[
[1, 2],
[3, 4],
]
)
).toEqual([
[1, 2],
[3, 4]
[3, 4],
]);
expect(PositiveIntervals.union([[3, 4]], [])).toEqual([[3, 4]]);
expect(PositiveIntervals.union([[1, 2], [3, 4]], [])).toEqual([
expect(
PositiveIntervals.union(
[
[1, 2],
[3, 4],
],
[]
)
).toEqual([
[1, 2],
[3, 4]
[3, 4],
]);
expect(PositiveIntervals.union([[3, 3]], [])).toEqual([[3, 3]]);
expect(PositiveIntervals.union([], [[3, 3]])).toEqual([[3, 3]]);
@@ -44,17 +73,37 @@ describe("union", () => {
expect(PositiveIntervals.union([[2, 3]], [[1, 2]])).toEqual([[1, 3]]);
expect(PositiveIntervals.union([[1, 2]], [[3, 4]])).toEqual([
[1, 2],
[3, 4]
[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]
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]
[4, 4],
]);
});
});
@@ -70,34 +119,43 @@ describe("intersection", () => {
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]
[1, 10],
]);
expect(PositiveIntervals.intersection([[1, 10]], [[2, 8]])).toEqual([
[2, 8]
[2, 8],
]);
expect(PositiveIntervals.intersection([[2, 8]], [[1, 10]])).toEqual([
[2, 8]
[2, 8],
]);
expect(PositiveIntervals.intersection([[1, 10]], [[2, 12]])).toEqual([
[2, 10]
[2, 10],
]);
expect(PositiveIntervals.intersection([[2, 12]], [[1, 10]])).toEqual([
[2, 10]
[2, 10],
]);
expect(PositiveIntervals.intersection([[1, 10]], [[1, 8]])).toEqual([
[1, 8]
[1, 8],
]);
expect(PositiveIntervals.intersection([[1, 8]], [[1, 10]])).toEqual([
[1, 8]
[1, 8],
]);
expect(
PositiveIntervals.intersection(
[[1, 10]],
[
[1, 2],
[6, 9],
]
)
).toEqual([
[1, 2],
[6, 9],
]);
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]
[1, 2],
]);
});
});
@@ -110,32 +168,66 @@ describe("difference", () => {
});
test("simple", () => {
expect(PositiveIntervals.difference([[1, 2], [3, 4]], [])).toEqual([
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]
[3, 4],
]);
expect(
PositiveIntervals.difference([[0, 2638]], [[0, 1363], [2055, 2638]])
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]])
PositiveIntervals.difference(
[
[0, 1363],
[2055, 2638],
],
[[0, 2638]]
)
).toEqual([]);
expect(PositiveIntervals.difference([[0, 10]], [[0, 1]])).toEqual([
[1, 10]
[1, 10],
]);
expect(PositiveIntervals.difference([[0, 10]], [[1, 2]])).toEqual([
[0, 1],
[2, 10]
[2, 10],
]);
expect(PositiveIntervals.difference([[0, 10]], [[9, 10]])).toEqual([
[0, 9]
[0, 9],
]);
});
});
@@ -2,9 +2,6 @@ import {
sortArray,
sortIndex,
lowerBound,
upperBound,
lowerBoundIndirect,
upperBoundIndirect
} from "../../../src/util/typedCrossfilter/sort";
/*
@@ -40,7 +37,7 @@ describe("sortArray", () => {
["a", "b", "0", "1"],
[0, "a", true, null, undefined, 3.1415],
fillRand(new Array(1000)),
["a", NaN, null, pInf]
["a", NaN, null, pInf],
].map((val, idx) =>
test(`JS vals ${idx}`, () => {
expect(sortArray(val)).toMatchObject(val.sort());
@@ -49,7 +46,7 @@ describe("sortArray", () => {
});
describe("finite numbers", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
[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])
@@ -68,7 +65,7 @@ describe("sortArray", () => {
});
describe("non-finite numbers", () => {
test("inifinity", () => {
test("infinity", () => {
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
new Float32Array([nInf, 0, 1, 2, pInf])
);
@@ -131,7 +128,7 @@ describe("sortArray", () => {
describe("sortIndex", () => {
describe("finite numbers", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
[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));
@@ -1,6 +1,6 @@
import {
sliceByIndex,
makeSortIndex
makeSortIndex,
} from "../../../src/util/typedCrossfilter/util";
import { rangeFill as fillRange } from "../../../src/util/range";
+13 -4
View File
@@ -2,15 +2,24 @@ module.exports = {
babelrc: false,
cacheDirectory: true,
presets: [
["modern-browsers", { loose: true, modules: false }],
"@babel/preset-react"
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: 3,
modules: false,
},
],
"@babel/preset-react",
],
plugins: [
"@babel/plugin-proposal-function-bind",
["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
["@babel/plugin-proposal-private-methods", { loose: true }],
["@babel/plugin-proposal-private-property-in-object", { loose: true }],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
"@babel/plugin-proposal-nullish-coalescing-operator",
],
};
+13 -4
View File
@@ -1,17 +1,26 @@
module.exports = {
babelrc: false,
presets: [
["modern-browsers", { loose: true, modules: false }],
"@babel/preset-react"
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: 3,
modules: false,
},
],
"@babel/preset-react",
],
plugins: [
"@babel/plugin-proposal-function-bind",
["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
["@babel/plugin-proposal-private-methods", { loose: true }],
["@babel/plugin-proposal-private-property-in-object", { loose: 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"
]
"@babel/plugin-proposal-nullish-coalescing-operator",
],
};
+54 -17
View File
@@ -1,18 +1,48 @@
module.exports = {
root: true,
parser: "babel-eslint",
extends: ["airbnb", "prettier", "prettier/react"],
extends: [
"airbnb",
"plugin:eslint-comments/recommended",
"plugin:@blueprintjs/recommended",
"plugin:compat/recommended",
"plugin:jsx-a11y/recommended",
// (thuang) disable eslint formatting rules, so prettier can do its job
// Do not use `plugin:prettier/recommended` per doc below:
// https://prettier.io/docs/en/integrating-with-linters.html
"prettier",
],
settings: {
// AbortController is not supported in iOS Safari 10.3, Chrome 61
// Headers is not supported in iOS Safari 10.3
polyfills: ["Headers", "AbortController"],
},
env: { browser: true, commonjs: true, es6: true },
globals: { expect: true },
globals: {
expect: true,
jest: true,
jestPuppeteer: true,
it: true,
page: true,
browser: true,
context: true,
beforeEach: true,
},
parser: "@babel/eslint-parser",
parserOptions: {
ecmaVersion: 2017,
sourceType: "module",
ecmaFeatures: {
jsx: true,
generators: true
}
generators: true,
},
babelOptions: {
configFile: "./configuration/babel/babel.prod.js",
},
},
rules: {
"react/jsx-no-target-blank": "off",
"eslint-comments/require-description": ["error"],
"no-magic-numbers": "off",
"no-nested-ternary": "off",
"func-style": "off",
@@ -21,13 +51,7 @@ module.exports = {
"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",
@@ -35,13 +59,26 @@ module.exports = {
"react/prop-types": [0],
"space-before-function-paren": "off",
"function-paren-newline": "off",
"prefer-destructuring": ["error", { object: true, array: false }]
"prefer-destructuring": ["error", { object: true, array: false }],
"import/prefer-default-export": "off",
"no-restricted-syntax": [
"error",
"ForInStatement",
"LabeledStatement",
"WithStatement",
],
"import/no-extraneous-dependencies": [
"error",
{
devDependencies: true,
},
],
},
overrides: [
{
files: ["**/*.test.js"],
env: {
jest: true // now **/*.test.js files' env has both es6 *and* jest
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"]
@@ -51,8 +88,8 @@ module.exports = {
"jest/no-focused-tests": "error",
"jest/no-identical-title": "error",
"jest/prefer-to-have-length": "warn",
"jest/valid-expect": "error"
}
}
]
"jest/valid-expect": "error",
},
},
],
};
@@ -0,0 +1,4 @@
module.exports = {
"*.js": "eslint --fix",
"**/*": "prettier --write --ignore-unknown",
};
@@ -0,0 +1,53 @@
/* eslint-disable import/no-extraneous-dependencies -- this file is a devDependency*/
const cheerio = require("cheerio");
const crypto = require("crypto");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const digest = (str) => {
const hash = crypto.createHash("sha256").update(str, "utf8").digest("base64");
return `sha256-${hash}`;
};
class CspHashPlugin {
constructor(opts) {
this.opts = { ...opts };
}
apply(compiler) {
compiler.hooks.compilation.tap("CspHashPlugin", (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
"CspHashPlugin",
(data, cb) => {
const { filename } = this.opts;
const $ = cheerio.load(data.html, { decodeEntities: false });
if (filename) {
const results = {};
results["script-hashes"] = $("script:not([src]):not([no-csp-hash])")
.map((i, elmt) => digest($(elmt).html()))
.get();
const json = JSON.stringify(results);
compilation.assets[filename] = {
source: () => json,
size: () => json.length,
};
}
// Remove no-csp-hash attributes. Cheerio does not parse Jinja templates
// correctly, so we brute force this with a regular expression.
data.html = data.html.replace(
/(<script .*)no-csp-hash(.*>)/,
(match, p1, p2) => [p1, p2].join("")
);
// Tell webpack to move on
cb(null, data);
}
);
});
}
}
module.exports = CspHashPlugin;
/* eslint-enable import/no-extraneous-dependencies -- enable*/
@@ -0,0 +1,75 @@
<script>
/** Reflect ANY changes to this script in the script hash in `server/eb/app.py` **/
var root = document.getElementById("root");
root.remove();
var portals = document.getElementsByClassName("bp3-portal");
for (var i = 0; i < portals.length; i += 1) {
portals[i].remove();
}
</script>
<div
style="
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
text-align: center;
justify-content: center;
align-items: center;
background: #8080801a;
font-family: 'Roboto Condensed, sans serif';
"
>
<img
src="https://cellxgene.cziscience.com/s3/cellxgene/static/images/cellxgene-logo.png"
style="width: 320px;"
/>
<div
style="
margin-top: 16px;
background: white;
width: 40vw;
border-radius: 4px;
padding: 24px 64px;
-webkit-box-shadow: 0px 0px 3px 2px rgba(0, 0, 0, 0.38);
-moz-box-shadow: 0px 0px 3px 2px rgba(0, 0, 0, 0.38);
box-shadow: 0px 0px 3px 2px rgba(0, 0, 0, 0.38);
max-width: 550px;
"
>
<div style="margin-bottom: 0; font-weight: bolder; font-size: 1.2em;">
Unsupported Browser
</div>
<div style="margin-top: 0;">
cellxgene is currently supported on the following browsers
</div>
<div
style="display: flex; justify-content: space-around; margin-top: 16px;"
>
<a
href="https://www.google.com/chrome/?hl=en%22"
aria-label="Download Google Chrome"
>
<img
src="https://cellxgene.cziscience.com/s3/cellxgene/static/images/chrome.png"
style="width: 80px; height: 80px;"
/>
<div>Chrome &gt; 60</div>
</a>
<a href="https://www.mozilla.com/firefox/" aria-label="Download Firefox">
<img
src="https://cellxgene.cziscience.com/s3/cellxgene/static/images/firefox.png"
style="width: 80px; height: 80px;"
/>
<div>Firefox ≥ 60</div>
</a>
<a href="//www.microsoft.com/edge" aria-label="Download Edge">
<img
src="https://cellxgene.cziscience.com/s3/cellxgene/static/images/edge.png"
style="width: 80px; height: 80px;"
/>
<div>Edge ≥ 79</div>
</a>
</div>
</div>
</div>
@@ -1,93 +1,81 @@
// jshint esversion: 6
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const FaviconsWebpackPlugin = require("favicons-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { merge } = require("webpack-merge");
const sharedConfig = require("./webpack.config.shared");
const babelOptions = require("../babel/babel.dev");
const src = path.resolve("src");
const fonts = path.resolve("src/fonts");
const nodeModules = path.resolve("node_modules");
const babelOptions = require("../babel/babel.dev");
module.exports = {
const devConfig = {
mode: "development",
devtool: "eval",
entry: ["./src/index"],
output: {
path: path.resolve("build"),
pathinfo: true,
filename: "static/js/bundle.js",
publicPath: "/"
},
module: {
rules: [
{
test: /\.js$/,
include: src,
test: /\.jsx?$/,
loader: "babel-loader",
options: babelOptions
options: babelOptions,
},
{
test: /\.css$/,
include: src,
exclude: [path.resolve(src, "index.css")],
loader: [
{
loader: "style-loader"
},
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[name]__[local]___[hash:base64:5]"
}
}
}
]
},
{
test: /index\.css$/,
include: [path.resolve(src, "index.css")],
loader: [
{
loader: "style-loader"
},
{
loader: "css-loader"
}
]
},
{ test: /\.json$/, include: [src, nodeModules], loader: "json-loader" },
{
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
include: nodeModules,
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2|otf)$/i,
loader: "file-loader",
query: { name: "static/media/[name].[ext]" }
include: [nodeModules, fonts],
options: {
name: "static/assets/[name].[ext]",
// (thuang): This is needed to make sure @font url path is '/static/assets/'
publicPath: "/",
},
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
include: fonts,
loader: "file-loader",
query: { name: "static/fonts/[name].[ext]" }
},
{
test: /\.(mp4|webm)(\?.*)?$/,
include: [src, nodeModules],
loader: "url-loader",
query: { limit: 10000, name: "static/media/[name].[ext]" }
}
]
],
},
plugins: [
new HtmlWebpackPlugin({
inject: true,
template: path.resolve("index.html"),
favicon: path.resolve("favicon.png")
}),
new FaviconsWebpackPlugin({
logo: "./favicon.png",
prefix: "static/img/",
favicons: {
icons: {
android: false,
appleIcon: false,
appleStartup: false,
coast: false,
firefox: false,
windows: false,
yandex: false,
},
},
}),
new MiniCssExtractPlugin({
filename: "static/[name].css",
}),
new webpack.NoEmitOnErrorsPlugin(),
new webpack.DefinePlugin({
__REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })"
})
]
__REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })",
}),
new webpack.DefinePlugin({
// webpack 5 no longer polyfills NodeJS modules, so fake the one we need
"process.env": JSON.stringify({
NODE_ENV: process.env.NODE_ENV || "development",
CXG_SERVER_PORT: process.env.CXG_SERVER_PORT || "5005",
}),
}),
],
infrastructureLogging: {
level: "warn",
},
};
module.exports = merge(sharedConfig, devConfig);
@@ -1,124 +1,96 @@
// jshint esversion: 6
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const TerserJSPlugin = require("terser-webpack-plugin");
const FaviconsWebpackPlugin = require("favicons-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
const src = path.resolve("src");
const fonts = path.resolve("src/fonts");
const nodeModules = path.resolve("node_modules");
const { merge } = require("webpack-merge");
const babelOptions = require("../babel/babel.prod");
const publicPath = "";
const CspHashPlugin = require("./cspHashPlugin");
const sharedConfig = require("./webpack.config.shared");
module.exports = {
const fonts = path.resolve("src/fonts");
const nodeModules = path.resolve("node_modules");
const prodConfig = {
mode: "production",
bail: true,
cache: false,
devtool: "cheap-source-map",
entry: ["./src/index.js"],
output: {
path: path.resolve("build"),
filename: "static/js/[name].[chunkhash:8].js",
publicPath
filename: "static/[name]-[contenthash].js",
},
optimization: {
minimize: true,
minimizer: [new TerserJSPlugin({})],
},
devtool: "source-map",
module: {
rules: [
{
test: /\.js$/,
include: src,
test: /\.jsx?$/,
loader: "babel-loader",
options: babelOptions
options: babelOptions,
},
{
test: /\.css$/,
include: src,
exclude: [path.resolve(src, "index.css")],
use: [
MiniCssExtractPlugin.loader,
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[name]__[local]___[hash:base64:5]"
},
importLoaders: 1
}
}
]
},
{
test: /index\.css$/,
include: [path.resolve(src, "index.css")],
use: [
MiniCssExtractPlugin.loader,
{
loader: "css-loader",
options: {
importLoaders: 1
}
}
]
},
{
test: /\.json$/,
include: [src, nodeModules],
loader: "json-loader",
exclude: /manifest.json$/
},
{
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
include: nodeModules,
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2|otf)$/i,
loader: "file-loader",
query: { name: "static/media/[name].[ext]" }
include: [nodeModules, fonts],
options: {
name: "static/assets/[name]-[contenthash].[ext]",
// (thuang): This is needed to make sure @font url path is '../static/assets/'
publicPath: "static/",
},
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
include: fonts,
loader: "file-loader",
query: { name: "static/fonts/[name].[ext]" }
},
{
test: /\.(mp4|webm)(\?.*)?$/,
include: [src, nodeModules],
loader: "url-loader",
query: { limit: 10000, name: "static/media/[name].[hash:8].[ext]" }
}
]
],
},
plugins: [
new HtmlWebpackPlugin({
inject: "body",
filename: "index.html",
template: path.resolve("index_template.html"),
favicon: path.resolve("favicon.png"),
inlineSource: ".(js|css)$",
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true
}
decodeEntities: false,
minify: false,
}),
new CleanWebpackPlugin({
verbose: true,
protectWebpackAssets: false,
cleanAfterEveryBuildPatterns: ["main.js", "main.css"],
}),
new FaviconsWebpackPlugin({
logo: "./favicon.png",
prefix: "static/assets/",
favicons: {
icons: {
android: false,
appleIcon: false,
appleStartup: false,
coast: false,
firefox: false,
windows: false,
yandex: false,
},
},
}),
new HtmlWebpackInlineSourcePlugin(),
new MiniCssExtractPlugin({
filename: "static/css/[name].[contenthash:8].css"
filename: "static/[name]-[contenthash].css",
}),
new CspHashPlugin({
filename: "csp-hashes.json",
}),
new webpack.DefinePlugin({
// webpack 5 no longer polyfills NodeJS modules, so fake the one we need
"process.env": JSON.stringify({
NODE_ENV: "production",
}),
}),
new SWPrecacheWebpackPlugin({
cacheId: "cellxgene",
filename: "service-worker.js"
})
],
performance: {
maxEntrypointSize: 2000000,
maxAssetSize: 2000000
}
maxAssetSize: 2000000,
},
};
module.exports = merge(sharedConfig, prodConfig);
@@ -0,0 +1,54 @@
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
// eslint-disable-next-line @blueprintjs/classes-constants -- incorrect match
const src = path.resolve("src");
const nodeModules = path.resolve("node_modules");
const publicPath = "";
module.exports = {
entry: [
"core-js",
"regenerator-runtime/runtime",
"whatwg-fetch",
"abort-controller/polyfill",
"./src/index",
],
output: {
path: path.resolve("build"),
publicPath,
},
module: {
rules: [
{
test: /\.css$/,
include: src,
exclude: [path.resolve(src, "index.css")],
use: [
MiniCssExtractPlugin.loader,
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[name]__[local]___[contenthash:base64:5]",
},
importLoaders: 1,
},
},
],
},
{
test: /index\.css$/,
include: [path.resolve(src, "index.css")],
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
{
test: /\.json$/,
include: [src, nodeModules],
loader: "json-loader",
exclude: /manifest.json$/,
},
],
},
};
+24 -6
View File
@@ -1,12 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>cell&times;gene</title>
<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;
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 {
@@ -20,9 +35,12 @@
</style>
</head>
<body>
<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>
</body>
</html>
+55 -31
View File
@@ -1,41 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>cell&times;gene</title>
<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;
}
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;
}
body {
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
}
* {
box-sizing: border-box;
}
</style>
</head>
<body>
<script type="text/javascript">
window.CELLXGENE = {};
window.CELLXGENE.API = {
prefix: `${location.origin}${location.pathname}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>
</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"
{{ ('integrity="%s"' % script.integrity) | safe if script.integrity }}
{{ ('crossorigin="%s"' % script.crossorigin) | safe if script.crossorigin }}
src="{{script.src | safe}}"
></script>
{%- endfor %}
<div id="root"></div>
{% for script in SCRIPTS %}
<script type="text/javascript" src="{{script | safe}}"></script>
{% endfor %}
</body>
{% for ils in INLINE_SCRIPTS %}
<script type="text/javascript" no-csp-hash>{% include ils %}</script>
{% endfor %}
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
/**
* `client/jest-puppeteer.config.js` is for configuring Puppeteer's launch config options
* `client/__tests__/e2e/puppeteer.setup.js` is for configuring `jest`, `browser`,
* and `page` objects
*/
const ENV_DEFAULT = require("../environment.default.json");
const jestEnv = process.env.JEST_ENV || ENV_DEFAULT.JEST_ENV;
const isHeadful =
process.env.HEADFUL === "true" || process.env.HEADLESS === "false";
const DEFAULT_LAUNCH_CONFIG = {
headless: !isHeadful,
args: ["--ignore-certificate-errors", "--ignore-ssl-errors"],
ignoreHTTPSErrors: true,
defaultViewport: {
width: 1280,
height: 960,
},
};
const LAUNCH_CONFIG_BY_ENV = {
[ENV_DEFAULT.DEBUG]: {
...DEFAULT_LAUNCH_CONFIG,
headless: false,
slowMo: 100,
devtools: true,
defaultViewport: {
width: DEFAULT_LAUNCH_CONFIG.defaultViewport.width,
height: DEFAULT_LAUNCH_CONFIG.defaultViewport.height + 560,
},
},
[ENV_DEFAULT.DEV]: {
...DEFAULT_LAUNCH_CONFIG,
headless: false,
slowMo: 5,
},
};
const launchConfig = LAUNCH_CONFIG_BY_ENV[jestEnv] || DEFAULT_LAUNCH_CONFIG;
module.exports = {
browserContext: "incognito",
launch: launchConfig,
};
+16934 -13268
View File
File diff suppressed because it is too large Load Diff
+127 -78
View File
@@ -1,24 +1,20 @@
{
"name": "cellxgene",
"version": "0.14.0",
"version": "1.0.0",
"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 ",
"backend-dev-anno-ontology": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-annotations --experimental-annotations-ontology",
"backend-dev-anno": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-annotations",
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
"build": "npm run clean && webpack --config",
"clean": "rimraf build",
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
"e2e": "node node_modules/jest/bin/jest.js --verbose false --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
"fmt": "eslint --fix src",
"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": "node node_modules/jest/bin/jest.js",
"unit-test": "node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e"
"dev": "npm run build -- configuration/webpack/webpack.config.dev.js",
"e2e": "jest --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
"e2e-annotations": "jest --config __tests__/e2e/e2eJestConfig.json e2e/e2eAnnotations.test.js",
"fmt": "eslint --fix src __tests__",
"lint": "eslint --fix src __tests__",
"prod": "npm run build -- configuration/webpack/webpack.config.prod.js",
"test": "jest --testPathIgnorePatterns e2e",
"prepare": "cd .. && husky install client/.husky"
},
"engineStrict": true,
"engines": {
@@ -33,90 +29,131 @@
"resolutions": {
"eslint-scope": "3.7.1"
},
"browserslist": [
"Chrome > 60",
"iOS >= 10.3",
"Firefox >= 60",
"Edge >= 79",
"not Explorer > 0",
"not Safari > 0"
],
"dependencies": {
"@blueprintjs/core": "^3.22.3",
"@blueprintjs/icons": "^3.13.0",
"@blueprintjs/select": "^3.11.2",
"@babel/eslint-parser": "^7.15.0",
"@blueprintjs/core": "^3.44.0",
"@blueprintjs/icons": "^3.19.0",
"@blueprintjs/popover2": "^0.11.2",
"@blueprintjs/select": "^3.16.0",
"abort-controller": "^3.0.0",
"core-js": "^3.16.3",
"d3": "^4.10.0",
"d3-scale-chromatic": "^1.5.0",
"flatbuffers": "^1.11.0",
"fuzzysort": "^1.1.4",
"gl-mat4": "^1.1.4",
"gl-matrix": "^3.1.0",
"gl-mat4": "^1.2.0",
"gl-matrix": "^3.3.0",
"gl-vec3": "^1.1.3",
"is-number": "^7.0.0",
"lodash": "^4.17.15",
"lodash.clonedeep": "^4.5.0",
"lodash.difference": "^4.5.0",
"lodash.foreach": "^4.5.0",
"lodash.pull": "^4.1.0",
"lodash.sortby": "^4.7.0",
"lodash.uniq": "^4.5.0",
"memoize-one": "^5.1.1",
"react": "^16.12.0",
"react-autocomplete": "^1.7.2",
"react-dom": "^16.12.0",
"react-flip-toolkit": "7.0.6",
"react-helmet": "^5.2.1",
"react-icons": "^3.8.0",
"react-redux": "^7.1.3",
"pako": "^2.0.3",
"react": "^17.0.2",
"react-async": "^10.0.1",
"react-dom": "^17.0.2",
"react-flip-toolkit": "^7.0.12",
"react-helmet": "^6.1.0",
"react-icons": "^4.2.0",
"react-redux": "^7.2.0",
"redux": "^4.0.5",
"redux-thunk": "^2.2.0",
"regl": "^1.3.13"
"redux-thunk": "^2.3.0",
"regenerator-runtime": "^0.13.7",
"regl": "^2.1.0",
"sha1": "^1.1.1",
"tinyqueue": "^2.0.3",
"whatwg-fetch": "^3.2.0"
},
"devDependencies": {
"@babel/core": "^7.7.7",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-proposal-decorators": "^7.7.4",
"@babel/plugin-proposal-export-namespace-from": "^7.7.4",
"@babel/plugin-proposal-function-bind": "^7.7.4",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.7.4",
"@babel/plugin-proposal-optional-chaining": "^7.7.5",
"@babel/plugin-transform-react-constant-elements": "^7.7.4",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.7",
"@babel/preset-react": "^7.7.4",
"@babel/register": "^7.7.7",
"@babel/runtime": "^7.7.7",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
"babel-loader": "^8.0.6",
"babel-preset-modern-browsers": "^14.1.1",
"chalk": "^3.0.0",
"connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.1",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.9.0",
"eslint-loader": "^3.0.3",
"@babel/core": "^7.13.16",
"@babel/plugin-proposal-class-properties": "^7.10.4",
"@babel/plugin-proposal-decorators": "^7.13.15",
"@babel/plugin-proposal-export-namespace-from": "^7.10.4",
"@babel/plugin-proposal-function-bind": "^7.10.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
"@babel/plugin-proposal-optional-chaining": "^7.10.4",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/plugin-transform-react-constant-elements": "^7.13.13",
"@babel/plugin-transform-runtime": "^7.13.15",
"@babel/preset-env": "^7.13.15",
"@babel/preset-react": "^7.13.13",
"@babel/register": "^7.13.16",
"@babel/runtime": "^7.13.16",
"@blueprintjs/eslint-plugin": "^0.3.0",
"@sentry/webpack-plugin": "^1.15.0",
"babel-jest": "^26.1.0",
"babel-loader": "^8.1.0",
"babel-preset-modern-browsers": "^15.0.2",
"chalk": "^4.1.0",
"cheerio": "^1.0.0-rc.6",
"clean-css": "^5.1.2",
"clean-webpack-plugin": "^4.0.0-alpha.0",
"codecov": "^3.7.1",
"css-loader": "^5.2.4",
"eslint": "^7.24.0",
"eslint-config-airbnb": "^18.2.0",
"eslint-config-prettier": "^8.2.0",
"eslint-plugin-compat": "^3.8.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "^2.19.1",
"eslint-plugin-jest": "^23.3.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.17.0",
"eslint-plugin-react-hooks": "^2.3.0",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-jest": "^24.3.5",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.0.8",
"expect-puppeteer": "^5.0.0",
"express": "^4.17.1",
"file-loader": "^5.0.2",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^3.2.0",
"jest": "^24.9.0",
"jest-puppeteer": "^4.4.0",
"json-loader": "^0.5.4",
"mini-css-extract-plugin": "^0.9.0",
"puppeteer": "^2.0.0",
"rimraf": "^3.0.0",
"serve-favicon": "^2.3.0",
"start-server-and-test": "^1.10.6",
"style-loader": "^1.1.2",
"sw-precache-webpack-plugin": "^0.11.5",
"url-loader": "^3.0.0",
"webpack": "^4.41.5",
"webpack-cli": "^3.3.10",
"webpack-dev-middleware": "^3.7.2"
"favicons": "^6.2.2",
"favicons-webpack-plugin": "^5.0.2",
"file-loader": "^6.0.0",
"html-webpack-plugin": "^5.3.1",
"husky": "^7.0.2",
"jest": "^27.0.6",
"jest-circus": "^27.0.6",
"jest-environment-puppeteer": "^5.0.1",
"jest-fetch-mock": "^3.0.3",
"jest-puppeteer": "^5.0.1",
"json-loader": "^0.5.7",
"lint-staged": "^10.2.11",
"lodash": "^4.17.21",
"lodash.every": "^4.6.0",
"lodash.filter": "^4.6.0",
"lodash.isnumber": "^3.0.3",
"lodash.map": "^4.6.0",
"lodash.zip": "^4.2.0",
"mini-css-extract-plugin": "^1.5.0",
"prettier": "^2.0.5",
"puppeteer": "^8.0.0",
"rimraf": "^3.0.2",
"serve-favicon": "^2.5.0",
"terser-webpack-plugin": "^5.1.1",
"webpack": "^5.34.0",
"webpack-cli": "^4.6.0",
"webpack-dev-middleware": "^4.1.0",
"webpack-merge": "^5.0.9"
},
"jest": {
"testMatch": [
"**/__tests__/**/?(*.)(spec|test).js?(x)"
],
"testURL": "http://localhost/",
"setupFiles": [
"./__tests__/setupMissingGlobals.js"
]
],
"coverageDirectory": "./coverage/",
"collectCoverage": true
},
"babel": {
"env": {
@@ -139,6 +176,18 @@
"loose": true
}
],
[
"@babel/plugin-proposal-private-methods",
{
"loose": true
}
],
[
"@babel/plugin-proposal-private-property-in-object",
{
"loose": true
}
],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-transform-react-constant-elements",
"@babel/plugin-transform-runtime",
+28 -24
View File
@@ -1,49 +1,53 @@
/* eslint-disable */
// jshint esversion: 6
var path = require("path");
var historyApiFallback = require("connect-history-api-fallback");
var chalk = require("chalk");
var express = require("express");
var favicon = require("serve-favicon");
var webpack = require("webpack");
var config = require("../configuration/webpack/webpack.config.dev");
var utils = require("./utils");
const chalk = require("chalk");
const express = require("express");
const favicon = require("serve-favicon");
const webpack = require("webpack");
const devMiddleware = require("webpack-dev-middleware");
const config = require("../configuration/webpack/webpack.config.dev");
const utils = require("./utils");
process.env.NODE_ENV = "development";
var PORT = process.env.PORT || 3000;
const CLIENT_PORT = process.env.CXG_CLIENT_PORT;
const { CXG_SERVER_PORT } = process.env;
const API = {
prefix: `http://localhost:${CXG_SERVER_PORT}/`,
};
// Set up compiler
var compiler = webpack(config);
const compiler = webpack(config);
compiler.plugin("invalid", () => {
compiler.hooks.invalid.tap("invalid", () => {
utils.clearConsole();
console.log("Compiling...");
});
compiler.plugin("done", stats => {
utils.formatStats(stats, PORT);
compiler.hooks.done.tap("done", (stats) => {
utils.formatStats(stats, CLIENT_PORT);
});
// Launch server
var app = express();
app.use(historyApiFallback({ verbose: false }));
const app = express();
app.use(
require("webpack-dev-middleware")(compiler, {
logLevel: "warn",
publicPath: config.output.publicPath
devMiddleware(compiler, {
publicPath: config.output.publicPath,
index: true,
})
);
app.use(favicon("./favicon.png"));
app.get("*", (req, res) => {
res.sendFile(path.resolve("index.html"));
app.get("/logout", async (req, res) => {
try {
res.redirect(`${API.prefix}logout?dataset=http://localhost:${CLIENT_PORT}`);
} catch (err) {
console.error(err);
}
});
app.listen(PORT, err => {
app.listen(CLIENT_PORT, (err) => {
if (err) {
console.log(err);
return;
+7 -5
View File
@@ -8,7 +8,9 @@ function isLikelyASyntaxError(message) {
return message.indexOf(friendlySyntaxErrorLabel) !== -1;
}
function formatMessage(message) {
function formatMessage(messageObject) {
let { message, details } = messageObject;
if (details) message = message + ": " + details;
return message
.replace("Module build failed: SyntaxError:", friendlySyntaxErrorLabel)
.replace(
@@ -36,10 +38,10 @@ var formatStats = (stats, port) => {
var json = stats.toJson();
var formattedErrors = json.errors.map(
message => "Error in " + formatMessage(message)
(message) => "Error in " + formatMessage(message)
);
var formattedWarnings = json.warnings.map(
message => "Warning in " + formatMessage(message)
(message) => "Warning in " + formatMessage(message)
);
if (hasErrors) {
@@ -48,7 +50,7 @@ var formatStats = (stats, port) => {
if (formattedErrors.some(isLikelyASyntaxError)) {
formattedErrors = formattedErrors.filter(isLikelyASyntaxError);
}
formattedErrors.forEach(message => {
formattedErrors.forEach((message) => {
console.log(message);
console.log();
});
@@ -58,7 +60,7 @@ var formatStats = (stats, port) => {
if (hasWarnings) {
console.log(chalk.yellow("Compiled with warnings."));
console.log();
formattedWarnings.forEach(message => {
formattedWarnings.forEach((message) => {
console.log(message);
console.log();
});
+455
View File
@@ -0,0 +1,455 @@
/*
Action creators for user annotation
*/
import difference from "lodash.difference";
import pako from "pako";
import * as globals from "../globals";
import { MatrixFBS, AnnotationsHelpers } from "../util/stateManager";
const { isUserAnnotation } = AnnotationsHelpers;
export const annotationCreateCategoryAction =
(newCategoryName, categoryToDuplicate) => async (dispatch, getState) => {
/*
Add a new user-created category to the obs annotations.
Arguments:
newCategoryName - string name for the category.
categoryToDuplicate - obs category to use for initial values, or null.
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
getState();
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
const { schema } = prevAnnoMatrix;
/* name must be a string, non-zero length */
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
throw new Error("user annotations require string name");
/* ensure the name isn't already in use! */
if (schema.annotations.obsByName[newCategoryName])
throw new Error("name collision on annotation category create");
let initialValue;
let newSchema;
let ctor;
if (categoryToDuplicate) {
/* if we are duplicating a category, retrieve it */
const catDupSchema = schema.annotations.obsByName[categoryToDuplicate];
const catDupType = catDupSchema?.type;
if (catDupType !== "string" && catDupType !== "categorical")
throw new Error(
"categoryToDuplicate does not exist or has invalid type"
);
const catToDupDf = await prevAnnoMatrix
.base()
.fetch("obs", categoryToDuplicate);
const col = catToDupDf.col(categoryToDuplicate);
initialValue = col.asArray();
const { categories } = col.summarizeCategorical();
// all user-created annotations must have the unassigned category
if (!categories.includes(globals.unassignedCategoryLabel)) {
categories.push(globals.unassignedCategoryLabel);
}
ctor = initialValue.constructor;
newSchema = {
...catDupSchema,
name: newCategoryName,
categories,
writable: true,
};
} else {
/* else assign to the standard default value */
initialValue = globals.unassignedCategoryLabel;
ctor = Array;
newSchema = {
name: newCategoryName,
type: "categorical",
categories: [globals.unassignedCategoryLabel],
writable: true,
};
}
const obsCrossfilter = prevObsCrossfilter.addObsColumn(
newSchema,
ctor,
initialValue
);
dispatch({
type: "annotation: create category",
data: newCategoryName,
categoryToDuplicate,
annoMatrix: obsCrossfilter.annoMatrix,
obsCrossfilter,
});
};
export const annotationRenameCategoryAction =
(oldCategoryName, newCategoryName) => (dispatch, getState) => {
/*
Rename a user-created annotation category
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
getState();
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
if (!isUserAnnotation(prevAnnoMatrix, oldCategoryName))
throw new Error("not a user annotation");
/* name must be a string, non-zero length */
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
throw new Error("user annotations require string name");
if (oldCategoryName === newCategoryName) return;
const obsCrossfilter = prevObsCrossfilter.renameObsColumn(
oldCategoryName,
newCategoryName
);
dispatch({
type: "annotation: category edited",
annoMatrix: obsCrossfilter.annoMatrix,
obsCrossfilter,
metadataField: oldCategoryName,
newCategoryText: newCategoryName,
data: newCategoryName,
});
};
export const annotationDeleteCategoryAction =
(categoryName) => (dispatch, getState) => {
/*
Delete a user-created category
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
getState();
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
throw new Error("not a user annotation");
const obsCrossfilter = prevObsCrossfilter.dropObsColumn(categoryName);
dispatch({
type: "annotation: delete category",
annoMatrix: obsCrossfilter.annoMatrix,
obsCrossfilter,
metadataField: categoryName,
});
};
export const annotationCreateLabelInCategory =
(categoryName, labelName, assignSelected) => async (dispatch, getState) => {
/*
Add a new label to a user-defined category. If assignSelected is true, assign
the label to all currently selected cells.
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
getState();
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
throw new Error("not a user annotation");
let obsCrossfilter = prevObsCrossfilter.addObsAnnoCategory(
categoryName,
labelName
);
if (assignSelected) {
obsCrossfilter = await obsCrossfilter.setObsColumnValues(
categoryName,
prevObsCrossfilter.allSelectedLabels(),
labelName
);
}
dispatch({
type: "annotation: add new label to category",
annoMatrix: obsCrossfilter.annoMatrix,
obsCrossfilter,
metadataField: categoryName,
newLabelText: labelName,
assignSelectedCells: assignSelected,
});
};
export const annotationDeleteLabelFromCategory =
(categoryName, labelName) => async (dispatch, getState) => {
/*
delete a label from a user-defined category
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
getState();
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
throw new Error("not a user annotation");
const obsCrossfilter = await prevObsCrossfilter.removeObsAnnoCategory(
categoryName,
labelName,
globals.unassignedCategoryLabel
);
dispatch({
type: "annotation: delete label",
metadataField: categoryName,
label: labelName,
annoMatrix: obsCrossfilter.annoMatrix,
obsCrossfilter,
});
};
export const annotationRenameLabelInCategory =
(categoryName, oldLabelName, newLabelName) => async (dispatch, getState) => {
/*
label name change
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
getState();
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
throw new Error("not a user annotation");
let obsCrossfilter = await prevObsCrossfilter.resetObsColumnValues(
categoryName,
oldLabelName,
newLabelName
);
obsCrossfilter = await obsCrossfilter.removeObsAnnoCategory(
categoryName,
oldLabelName,
globals.unassignedCategoryLabel
);
dispatch({
type: "annotation: label edited",
editedLabel: newLabelName,
metadataField: categoryName,
label: oldLabelName,
annoMatrix: obsCrossfilter.annoMatrix,
obsCrossfilter,
});
};
export const annotationLabelCurrentSelection =
(categoryName, labelName) => async (dispatch, getState) => {
/*
set the label on all currently selected
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
getState();
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
throw new Error("not a user annotation");
const obsCrossfilter = await prevObsCrossfilter.setObsColumnValues(
categoryName,
prevObsCrossfilter.allSelectedLabels(),
labelName
);
dispatch({
type: "annotation: label current cell selection",
metadataField: categoryName,
label: labelName,
obsCrossfilter,
annoMatrix: obsCrossfilter.annoMatrix,
});
};
function writableAnnotations(annoMatrix) {
return annoMatrix.schema.annotations.obs.columns
.filter((s) => s.writable)
.map((s) => s.name);
}
export const needToSaveObsAnnotations = (annoMatrix, lastSavedAnnoMatrix) => {
/*
Return true if there are LIKELY user-defined annotation modifications between the two
annoMatrices. Technically not an action creator, but intimately intertwined
with the save process.
Two conditions will trigger a need to save:
* the collection of user-defined columns have changed
* the contents of the user-defined columns have change
*/
annoMatrix = annoMatrix.base();
// if the annoMatrix hasn't changed, we are guaranteed no changes to the matrix schema or contents.
if (annoMatrix === lastSavedAnnoMatrix) return false;
// if the schema has changed, we need to save
const currentWritable = writableAnnotations(annoMatrix);
if (difference(currentWritable, writableAnnotations(lastSavedAnnoMatrix))) {
return true;
}
// no schema changes; check for change in contents
return currentWritable.some(
(col) => annoMatrix.col(col) !== lastSavedAnnoMatrix.col(col)
);
};
export const saveObsAnnotationsAction = () => async (dispatch, getState) => {
/*
Save the user-created obs annotations IF any have changed.
*/
const state = getState();
const { annotations, autosave } = state;
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
const { lastSavedAnnoMatrix, saveInProgress } = autosave;
const annoMatrix = state.annoMatrix.base();
if (saveInProgress || annoMatrix === lastSavedAnnoMatrix) return;
if (!needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix)) {
dispatch({
type: "writable obs annotations - save complete",
lastSavedAnnoMatrix: annoMatrix,
});
return;
}
/*
Else, we really do need to save
*/
dispatch({
type: "writable obs annotations - save started",
});
const df = await annoMatrix.fetch("obs", writableAnnotations(annoMatrix));
const matrix = MatrixFBS.encodeMatrixFBS(df);
const compressedMatrix = pako.deflate(matrix);
try {
const queryString =
!dataCollectionNameIsReadOnly && !!dataCollectionName
? `?annotation-collection-name=${encodeURIComponent(
dataCollectionName
)}`
: "";
const res = await fetch(
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
{
method: "PUT",
body: compressedMatrix,
headers: new Headers({
"Content-Type": "application/octet-stream",
}),
credentials: "include",
}
);
if (res.ok) {
dispatch({
type: "writable obs annotations - save complete",
lastSavedAnnoMatrix: annoMatrix,
});
} else {
dispatch({
type: "writable obs annotations - save error",
message: `HTTP error ${res.status} - ${res.statusText}`,
res,
});
}
} catch (error) {
dispatch({
type: "writable obs annotations - save error",
message: error.toString(),
error,
});
}
};
export const saveGenesetsAction = () => async (dispatch, getState) => {
const state = getState();
// bail if gene sets not available, or in readonly mode.
const { config } = state;
const { lastTid, genesets } = state.genesets;
const genesetsAreAvailable =
config?.parameters?.annotations_genesets ?? false;
const genesetsReadonly =
config?.parameters?.annotations_genesets_readonly ?? true;
if (!genesetsAreAvailable || genesetsReadonly) {
// our non-save was completed!
return dispatch({
type: "autosave: genesets complete",
lastSavedGenesets: genesets,
});
}
dispatch({
type: "autosave: genesets started",
});
/* Create the JSON OTA data structure */
const tid = (lastTid ?? 0) + 1;
const genesetsOTA = [];
for (const [name, gs] of genesets) {
const genes = [];
for (const g of gs.genes.values()) {
genes.push({
gene_symbol: g.geneSymbol,
gene_description: g.geneDescription,
});
}
genesetsOTA.push({
geneset_name: name,
geneset_description: gs.genesetDescription,
genes,
});
}
const ota = {
tid,
genesets: genesetsOTA,
};
/* Save to server */
try {
const { dataCollectionNameIsReadOnly, dataCollectionName } =
state.annotations;
const queryString =
!dataCollectionNameIsReadOnly && !!dataCollectionName
? `?annotation-collection-name=${encodeURIComponent(
dataCollectionName
)}`
: "";
const res = await fetch(
`${globals.API.prefix}${globals.API.version}genesets${queryString}`,
{
method: "PUT",
headers: new Headers({
Accept: "application/json",
"Content-Type": "application/json",
}),
body: JSON.stringify(ota),
credentials: "include",
}
);
if (!res.ok) {
return dispatch({
type: "autosave: genesets error",
message: `HTTP error ${res.status} - ${res.statusText}`,
res,
});
}
return Promise.all([
dispatch({
type: "autosave: genesets complete",
lastSavedGenesets: genesets,
}),
dispatch({
type: "geneset: set tid",
tid,
}),
]);
} catch (error) {
return dispatch({
type: "autosave: genesets error",
message: error.toString(),
error,
});
}
};
+47
View File
@@ -0,0 +1,47 @@
/*
action creators related to embeddings choice
*/
import { AnnoMatrixObsCrossfilter } from "../annoMatrix";
import { _setEmbeddingSubset } from "../util/stateManager/viewStackHelpers";
export async function _switchEmbedding(
prevAnnoMatrix,
prevCrossfilter,
newEmbeddingName
) {
/*
DRY helper used by embedding action creators
*/
const base = prevAnnoMatrix.base();
const embeddingDf = await base.fetch("emb", newEmbeddingName);
const annoMatrix = _setEmbeddingSubset(prevAnnoMatrix, embeddingDf);
const obsCrossfilter = await new AnnoMatrixObsCrossfilter(
annoMatrix,
prevCrossfilter.obsCrossfilter
).select("emb", newEmbeddingName, {
mode: "all",
});
return [annoMatrix, obsCrossfilter];
}
export const layoutChoiceAction =
(newLayoutChoice) => async (dispatch, getState) => {
/*
On layout choice, make sure we have selected all on the previous layout, AND the new
layout.
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevCrossfilter } =
getState();
const [annoMatrix, obsCrossfilter] = await _switchEmbedding(
prevAnnoMatrix,
prevCrossfilter,
newLayoutChoice
);
dispatch({
type: "set layout choice",
layoutChoice: newLayoutChoice,
obsCrossfilter,
annoMatrix,
});
};
+149
View File
@@ -0,0 +1,149 @@
import { postUserErrorToast } from "../components/framework/toasters";
/*
Action creators for gene sets
Primarily used to keep the crossfilter and underlying data in sync with the UI.
The behavior manifest in these action creators:
Delete a gene set, will
* drop index & clear selection state on the gene set summary
* drop index & clear selection state of each gene in the geneset
Delete a gene from a gene set, will:
* drop index & clear selection state on the gene set summary
* drop index & clear selection state on the gene
Add a gene to a gene set, will:
* drop index & clear selection state on the gene set summary
* will NOT touch the selection state for the gene
Note that crossfilter indices are lazy created, as needed.
*/
export const genesetDelete = (genesetName) => (dispatch, getState) => {
const state = getState();
const { genesets } = state;
const gs = genesets?.genesets?.get(genesetName) ?? {};
const geneSymbols = Array.from(gs.genes.keys());
const obsCrossfilter = dropGeneset(dispatch, state, genesetName, geneSymbols);
if (genesetName === state.colors.colorAccessor) {
dispatch({
type: "reset colorscale",
});
}
dispatch({
type: "geneset: delete",
genesetName,
obsCrossfilter,
annoMatrix: obsCrossfilter.annoMatrix,
});
};
export const genesetAddGenes =
(genesetName, genes) => async (dispatch, getState) => {
const state = getState();
const { obsCrossfilter: prevObsCrossfilter, annoMatrix } = state;
const { schema } = annoMatrix;
const varIndex = schema.annotations.var.index;
const df = await annoMatrix.fetch("var", varIndex);
const geneNames = df.col(varIndex).asArray();
genes = genes.reduce((acc, gene) => {
if (geneNames.indexOf(gene.geneSymbol) === -1) {
postUserErrorToast(
`${gene.geneSymbol} doesn't appear to be a valid gene name.`
);
} else acc.push(gene);
return acc;
}, []);
const obsCrossfilter = dropGenesetSummaryDimension(
prevObsCrossfilter,
state,
genesetName
);
dispatch({
type: "continuous metadata histogram cancel",
continuousNamespace: { isGeneSetSummary: true },
selection: genesetName,
});
return dispatch({
type: "geneset: add genes",
genesetName,
genes,
obsCrossfilter,
annoMatrix: obsCrossfilter.annoMatrix,
});
};
export const genesetDeleteGenes =
(genesetName, geneSymbols) => (dispatch, getState) => {
const state = getState();
const obsCrossfilter = dropGeneset(
dispatch,
state,
genesetName,
geneSymbols
);
return dispatch({
type: "geneset: delete genes",
genesetName,
geneSymbols,
obsCrossfilter,
annoMatrix: obsCrossfilter.annoMatrix,
});
};
/*
Private
*/
function dropGenesetSummaryDimension(obsCrossfilter, state, genesetName) {
const { annoMatrix, genesets } = state;
const varIndex = annoMatrix.schema.annotations?.var?.index;
const gs = genesets?.genesets?.get(genesetName) ?? {};
const genes = Array.from(gs.genes.keys());
const query = {
summarize: {
method: "mean",
field: "var",
column: varIndex,
values: genes,
},
};
return obsCrossfilter.dropDimension("X", query);
}
function dropGeneDimension(obsCrossfilter, state, gene) {
const { annoMatrix } = state;
const varIndex = annoMatrix.schema.annotations?.var?.index;
const query = {
where: {
field: "var",
column: varIndex,
value: gene,
},
};
return obsCrossfilter.dropDimension("X", query);
}
function dropGeneset(dispatch, state, genesetName, geneSymbols) {
const { obsCrossfilter: prevObsCrossfilter } = state;
const obsCrossfilter = geneSymbols.reduce(
(crossfilter, gene) => dropGeneDimension(crossfilter, state, gene),
dropGenesetSummaryDimension(prevObsCrossfilter, state, genesetName)
);
dispatch({
type: "continuous metadata histogram cancel",
continuousNamespace: { isGeneSetSummary: true },
selection: genesetName,
});
geneSymbols.forEach((g) =>
dispatch({
type: "continuous metadata histogram cancel",
continuousNamespace: { isUserDefined: true },
selection: g,
})
);
return obsCrossfilter;
}
+211 -339
View File
@@ -1,216 +1,142 @@
// jshint esversion: 6
import _ from "lodash";
import * as globals from "../globals";
import { Universe, MatrixFBS } from "../util/stateManager";
import { AnnoMatrixLoader, AnnoMatrixObsCrossfilter } from "../annoMatrix";
import {
catchErrorsWrap,
doJsonRequest,
doBinaryRequest,
dispatchNetworkErrorMessageToUser
dispatchNetworkErrorMessageToUser,
} from "../util/actionHelpers";
import { loadUserColorConfig } from "../util/stateManager/colorHelpers";
import * as selnActions from "./selection";
import * as annoActions from "./annotation";
import * as spatialActions from "./spatial";
import * as viewActions from "./viewStack";
import * as embActions from "./embedding";
import * as genesetActions from "./geneset";
function setGlobalConfig(config) {
/**
* Set any global run-time config not _exclusively_ managed by the config reducer.
* This should only set fields defined in globals.globalConfig.
*/
globals.globalConfig.maxCategoricalOptionsToDisplay =
config?.parameters?.["max-category-items"] ??
globals.globalConfig.maxCategoricalOptionsToDisplay;
}
/*
Bootstrap application with the initial data loading.
* /config - application configuration
* /schema - schema of dataframe
* /annotations - all metadata annotation
* /layout - all default layout
return promise fetching user-configured colors
*/
async function userColorsFetchAndLoad(dispatch) {
return fetchJson("colors").then((response) =>
dispatch({
type: "universe: user color load success",
userColors: loadUserColorConfig(response),
})
);
}
async function schemaFetch() {
return fetchJson("schema");
}
async function configFetch(dispatch) {
return fetchJson("config").then((response) => {
const config = { ...globals.configDefaults, ...response.config };
setGlobalConfig(config);
dispatch({
type: "configuration load complete",
config,
});
return config;
});
}
async function genesetsFetch(dispatch, config) {
/* request genesets ONLY if the backend supports the feature */
const defaultResponse = {
genesets: [],
tid: 0,
};
if (config?.parameters?.annotations_genesets ?? false) {
fetchJson("genesets").then((response) => {
dispatch({
type: "geneset: initial load",
data: response ?? defaultResponse,
});
});
} else {
dispatch({
type: "geneset: initial load",
data: defaultResponse,
});
}
}
function prefetchEmbeddings(annoMatrix) {
/*
prefetch requests for all embeddings
*/
const { schema } = annoMatrix;
const available = schema.layout.obs.map((v) => v.name);
available.forEach((embName) => annoMatrix.prefetch("emb", embName));
}
/*
Application bootstrap
*/
const doInitialDataLoad = () =>
catchErrorsWrap(async dispatch => {
catchErrorsWrap(async (dispatch) => {
dispatch({ type: "initial data load start" });
try {
/*
Step 1 - config & schema, all JSON
*/
const requestJson = ["config", "schema"]
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doJsonRequest(url));
const stepOneResults = await Promise.all(requestJson);
/* set config defaults */
const config = { ...globals.configDefaults, ...stepOneResults[0].config };
const schema = stepOneResults[1];
const [config, schema] = await Promise.all([
configFetch(dispatch),
schemaFetch(dispatch),
userColorsFetchAndLoad(dispatch),
]);
/*
Step 2 - dataframes, all binary. NOTE: uses results of step 1.
*/
/* only load names for var annotations, if possible*/
const varIndexName = schema?.schema?.annotations?.var?.index;
const varAnnotationsQuery = varIndexName
? `?annotation-name=${encodeURIComponent(varIndexName)}`
: "";
const varAnnotationsURL = `annotations/var${varAnnotationsQuery}`;
const requestBinary = ["annotations/obs", varAnnotationsURL, "layout/obs"]
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doBinaryRequest(url));
const stepTwoResults = await Promise.all(requestBinary);
const [obsAnno, varAnno, obsLayout] = [...stepTwoResults];
genesetsFetch(dispatch, config);
const universe = Universe.createUniverseFromResponse(
config,
schema,
obsAnno,
varAnno,
obsLayout
);
const baseDataUrl = `${globals.API.prefix}${globals.API.version}`;
const annoMatrix = new AnnoMatrixLoader(baseDataUrl, schema.schema);
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
prefetchEmbeddings(annoMatrix);
dispatch({
type: "configuration load complete",
config
});
dispatch({
type: "initial data load complete (universe exists)",
universe
type: "annoMatrix: init complete",
annoMatrix,
obsCrossfilter,
});
dispatch({ type: "initial data load complete" });
const defaultEmbedding = config?.parameters?.default_embedding;
const layoutSchema = schema?.schema?.layout?.obs ?? [];
if (
defaultEmbedding &&
layoutSchema.some((s) => s.name === defaultEmbedding)
) {
dispatch(embActions.layoutChoiceAction(defaultEmbedding));
}
} catch (error) {
dispatch({ type: "initial data load error", error });
}
}, 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) {
const state = getState();
const { universe } = state;
const varIndexName = universe.schema.annotations.var.index;
/* 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: varIndexName, values: geneNames }]
}
}
}),
headers: new Headers({
accept: "application/octet-stream",
"Content-Type": "application/json"
}),
credentials: "include"
}
);
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);
};
/* 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
});
}
return {
type: "color by expression",
gene,
};
}
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 requestUserDefinedGene = (gene) => ({
type: "request user defined gene success",
data: {
genes: [gene],
},
});
const dispatchDiffExpErrors = (dispatch, response) => {
switch (response.status) {
@@ -229,177 +155,123 @@ const dispatchDiffExpErrors = (dispatch, response) => {
dispatchNetworkErrorMessageToUser(msg);
dispatch({
type: "request differential expression error",
error: new Error(msg)
error: new Error(msg),
});
}
}
};
const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
dispatch,
getState
) => {
dispatch({ type: "request differential expression started" });
try {
/*
const requestDifferentialExpression =
(set1, set2, num_genes = 50) =>
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;
const varIndexName = universe.schema.annotations.var.index;
const { annoMatrix } = getState();
const varIndexName = annoMatrix.schema.annotations.var.index;
// Legal values are null, Array or TypedArray. Null is initial state.
if (!set1) set1 = [];
if (!set2) set2 = [];
// 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);
// 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 } } }
}),
credentials: "include"
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 } } },
}),
credentials: "include",
}
);
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
return dispatchDiffExpErrors(dispatch, res);
}
);
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], varIndexName)
);
/*
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: "reset centroid labels"
});
dispatch({
type: "clear scatterplot"
});
dispatch({
type: "reset World to eq Universe",
universe
});
dispatch({
type: "increment graph render counter"
});
dispatch({
type: "user reset end"
});
};
const saveObsAnnotations = () => async (dispatch, getState) => {
const { universe, annotations } = getState();
const { obsAnnotations, schema } = universe;
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
dispatch({
type: "writable obs annotations - save started"
});
const writableAnnotations = schema.annotations.obs.columns
.filter(s => s.writable)
.map(s => s.name);
const df = obsAnnotations.subset(null, writableAnnotations);
const matrix = MatrixFBS.encodeMatrixFBS(df);
try {
const queryString =
!dataCollectionNameIsReadOnly && !!dataCollectionName
? `?annotation-collection-name=${encodeURIComponent(
dataCollectionName
)}`
: "";
const res = await fetch(
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
{
method: "PUT",
body: matrix,
headers: new Headers({
"Content-Type": "application/octet-stream"
}),
credentials: "include"
const response = await res.json();
const varIndex = await annoMatrix.fetch("var", varIndexName);
const diffexpLists = { negative: [], positive: [] };
for (const polarity of Object.keys(diffexpLists)) {
diffexpLists[polarity] = response[polarity].map((v) => [
varIndex.at(v[0], varIndexName),
...v.slice(1),
]);
}
);
if (res.ok) {
dispatch({
type: "writable obs annotations - save complete",
obsAnnotations
/* then send the success case action through */
return dispatch({
type: "request differential expression success",
data: diffexpLists,
});
} else {
dispatch({
type: "writable obs annotations - save error",
message: `HTTP error ${res.status} - ${res.statusText}`,
res
} catch (error) {
return dispatch({
type: "request differential expression error",
error,
});
}
} catch (error) {
dispatch({
type: "writable obs annotations - save error",
message: error.toString(),
error
});
}
};
};
function fetchJson(pathAndQuery) {
return doJsonRequest(
`${globals.API.prefix}${globals.API.version}${pathAndQuery}`
);
}
export default {
regraph,
resetInterface,
requestSingleGeneExpressionCountsForColoringPOST,
requestDifferentialExpression,
requestUserDefinedGene,
doInitialDataLoad,
saveObsAnnotations
requestDifferentialExpression,
requestSingleGeneExpressionCountsForColoringPOST,
requestUserDefinedGene,
selectContinuousMetadataAction: selnActions.selectContinuousMetadataAction,
selectCategoricalMetadataAction: selnActions.selectCategoricalMetadataAction,
selectCategoricalAllMetadataAction:
selnActions.selectCategoricalAllMetadataAction,
graphBrushStartAction: selnActions.graphBrushStartAction,
graphBrushChangeAction: selnActions.graphBrushChangeAction,
graphBrushDeselectAction: selnActions.graphBrushDeselectAction,
graphBrushCancelAction: selnActions.graphBrushCancelAction,
graphBrushEndAction: selnActions.graphBrushEndAction,
graphLassoStartAction: selnActions.graphLassoStartAction,
graphLassoEndAction: selnActions.graphLassoEndAction,
graphLassoCancelAction: selnActions.graphLassoCancelAction,
graphLassoDeselectAction: selnActions.graphLassoDeselectAction,
clipAction: viewActions.clipAction,
subsetAction: viewActions.subsetAction,
resetSubsetAction: viewActions.resetSubsetAction,
annotationCreateCategoryAction: annoActions.annotationCreateCategoryAction,
annotationRenameCategoryAction: annoActions.annotationRenameCategoryAction,
annotationDeleteCategoryAction: annoActions.annotationDeleteCategoryAction,
annotationCreateLabelInCategory: annoActions.annotationCreateLabelInCategory,
annotationDeleteLabelFromCategory:
annoActions.annotationDeleteLabelFromCategory,
annotationRenameLabelInCategory: annoActions.annotationRenameLabelInCategory,
annotationLabelCurrentSelection: annoActions.annotationLabelCurrentSelection,
saveObsAnnotationsAction: annoActions.saveObsAnnotationsAction,
saveGenesetsAction: annoActions.saveGenesetsAction,
needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations,
layoutChoiceAction: embActions.layoutChoiceAction,
setCellSetFromSelection: selnActions.setCellSetFromSelection,
genesetDelete: genesetActions.genesetDelete,
genesetAddGenes: genesetActions.genesetAddGenes,
genesetDeleteGenes: genesetActions.genesetDeleteGenes,
requestSpatialMetadata: spatialActions.requestSpatialMetadata,
};
+192
View File
@@ -0,0 +1,192 @@
/*
Action creators for selection
*/
export const selectContinuousMetadataAction =
(type, query, range, oldProps = {}) =>
async (dispatch, getState) => {
const { obsCrossfilter: prevObsCrossfilter } = getState();
const selection = range
? {
mode: "range",
lo: range[0],
hi: range[1],
inclusive: true, // [lo, hi] incluisve selection
}
: { mode: "all" };
const obsCrossfilter = await prevObsCrossfilter.select(...query, selection);
dispatch({
type,
obsCrossfilter,
range,
...oldProps,
});
};
export const selectCategoricalMetadataAction =
(
type, // action type
metadataField, // annotation category name
labels,
label, // the label being selected/deselected
isSelected, // bool
oldProps = {}
) =>
async (dispatch, getState) => {
const { obsCrossfilter: prevObsCrossfilter, categoricalSelection } =
getState();
const labelSelectionState = new Map(categoricalSelection[metadataField]);
labels.forEach(
(l) => labelSelectionState.has(l) || labelSelectionState.set(l, true)
);
labelSelectionState.set(label, isSelected);
const values = Array.from(labelSelectionState.keys()).filter((k) =>
labelSelectionState.get(k)
);
const selection = {
mode: "exact",
values,
};
const obsCrossfilter = await prevObsCrossfilter.select(
"obs",
metadataField,
selection
);
dispatch({
type,
obsCrossfilter,
metadataField,
labelSelectionState,
...oldProps,
});
};
export const selectCategoricalAllMetadataAction =
(
type, // action type
metadataField, // annotation category name
labels,
isSelected, // bool, select all or none
oldProps = {}
) =>
async (dispatch, getState) => {
const { obsCrossfilter: prevObsCrossfilter, categoricalSelection } =
getState();
const labelSelectionState = new Map(categoricalSelection[metadataField]);
labels.forEach((label) => labelSelectionState.set(label, isSelected));
const selection = { mode: isSelected ? "all" : "none" };
const obsCrossfilter = await prevObsCrossfilter.select(
"obs",
metadataField,
selection
);
dispatch({
type,
obsCrossfilter,
metadataField,
labelSelectionState,
...oldProps,
});
};
/**
** Graph selection-related actions
**/
export const graphBrushStartAction = () =>
/* no change to crossfilter until a change fires */
({ type: "graph brush start" });
const _graphBrushWithinRectAction =
(type, embName, brushCoords) => async (dispatch, getState) => {
const { obsCrossfilter: prevObsCrossfilter } = getState();
const selection = { mode: "within-rect", ...brushCoords };
const obsCrossfilter = await prevObsCrossfilter.select(
"emb",
embName,
selection
);
dispatch({
type,
obsCrossfilter,
brushCoords,
});
};
const _graphAllAction = (type, embName) => async (dispatch, getState) => {
const { obsCrossfilter: prevObsCrossfilter } = getState();
const obsCrossfilter = await prevObsCrossfilter.select("emb", embName, {
mode: "all",
});
dispatch({
type,
obsCrossfilter,
});
};
export const graphBrushChangeAction = (embName, brushCoords) =>
_graphBrushWithinRectAction("graph brush change", embName, brushCoords);
export const graphBrushEndAction = (embName, brushCoords) =>
_graphBrushWithinRectAction("graph brush end", embName, brushCoords);
export const graphBrushCancelAction = (embName) =>
_graphAllAction("graph brush cancel", embName);
export const graphBrushDeselectAction = (embName) =>
_graphAllAction("graph brush deselect", embName);
export const graphLassoStartAction = () =>
/* no change to crossfilter until a change fires */
({ type: "graph lasso start" });
export const graphLassoCancelAction = (embName) =>
_graphAllAction("graph lasso cancel", embName);
export const graphLassoDeselectAction = (embName) =>
_graphAllAction("graph lasso cancel", embName);
export const graphLassoEndAction =
(embName, polygon) => async (dispatch, getState) => {
const { obsCrossfilter: prevObsCrossfilter } = getState();
const selection = {
mode: "within-polygon",
polygon,
};
const obsCrossfilter = await prevObsCrossfilter.select(
"emb",
embName,
selection
);
dispatch({
type: "graph lasso end",
obsCrossfilter,
polygon,
});
};
/*
Differential expression set selection
*/
export const setCellSetFromSelection = (cellSetId) => (dispatch, getState) => {
const { obsCrossfilter } = getState();
const selected = obsCrossfilter.allSelectedLabels();
dispatch({
type: `store current cell selection as differential set ${cellSetId}`,
data: selected.length > 0 ? selected : null,
});
};
+35
View File
@@ -0,0 +1,35 @@
import * as globals from "../globals";
export const requestSpatialMetadata = () => async (dispatch) => {
dispatch({ type: "request spatial metadata started" });
try {
const res = await fetch(
`${globals.API.prefix}${globals.API.version}spatial/meta`,
{
method: "GET",
headers: new Headers({
Accept: "application/json",
"Content-Type": "application/json",
}),
credentials: "include",
}
);
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
return null; // TODO need a dispatch //dispatchDiffExpErrors(dispatch, res);
}
const response = await res.json();
/* then send the success case action through */
return dispatch({
type: "request spatial metadata success",
data: response,
});
} catch (error) {
return dispatch({
type: "request spatial metadata error",
error,
});
}
};
+74
View File
@@ -0,0 +1,74 @@
/*
The following actions manage the view stack for annoMatrix.
Conventions used and assumed elsewhere in the code base:
* there will be zero or one clip view, and it will be the TOP view always.
* there will be zero or more subset views
In other words, in our current use, we do not stack multiple clip views but we do
stack multiple subsets.
If these conventions change, code elsewhere (eg. menubar/clip.js) will need to
change as well.
*/
import { AnnoMatrixObsCrossfilter } from "../annoMatrix";
import {
_clipAnnoMatrix,
_userSubsetAnnoMatrix,
_userResetSubsetAnnoMatrix,
} from "../util/stateManager/viewStackHelpers";
export const clipAction = (min, max) => (dispatch, getState) => {
/*
apply a clip to the current annoMatrix. By convention, the clip
view is ALWAYS the top view.
*/
const { annoMatrix: prevAnnoMatrix } = getState();
const annoMatrix = _clipAnnoMatrix(prevAnnoMatrix, min, max);
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
dispatch({
type: "set clip quantiles",
clipQuantiles: { min, max },
annoMatrix,
obsCrossfilter,
});
};
export const subsetAction = () => (dispatch, getState) => {
/*
Subset the annoMatrix to the current crossfilter selection by pushing a
subset view.
By convention, a clip view is ALWAYS the top view, so if present, pop
off and re-apply
*/
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
getState();
const annoMatrix = _userSubsetAnnoMatrix(
prevAnnoMatrix,
prevObsCrossfilter.allSelectedMask()
);
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
dispatch({
type: "subset to selection",
annoMatrix,
obsCrossfilter,
});
};
export const resetSubsetAction = () => (dispatch, getState) => {
/*
Reset the annoMatrix to all data. Because we may have multiple views
stacked, we pop them all. By convention, any clip transformation will
be the top of the stack, and must be preserved.
*/
const { annoMatrix: prevAnnoMatrix } = getState();
const annoMatrix = _userResetSubsetAnnoMatrix(prevAnnoMatrix);
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
dispatch({
type: "reset subset",
annoMatrix,
obsCrossfilter,
});
};
+650
View File
@@ -0,0 +1,650 @@
import {
Dataframe,
IdentityInt32Index,
dataframeMemo,
} from "../util/dataframe";
import {
_getColumnDimensionNames,
_getColumnSchema,
_schemaColumns,
_getWritableColumns,
} from "./schema";
import { indexEntireSchema } from "../util/stateManager/schemaHelpers";
import { _whereCacheGet, _whereCacheMerge } from "./whereCache";
import _shallowClone from "./clone";
import { _queryValidate, _queryCacheKey } from "./query";
const _dataframeCache = dataframeMemo(128);
export default class AnnoMatrix {
/*
Abstract base class for all AnnoMatrix objects. This class provides a proxy
to the annotated matrix data authoritatively served by the server/back-end.
AnnoMatrix instances are immutable, meaning that their schema and dimensionality
will not change, and simple object equality can be used to detect structural
changes. The actual data is cached, and not guaranteed to be present -- any
request to access data must be resolved by a fetch() call, which is async, and
may involve a server round-trip.
Guarantees made by the immutabilty, ie, any of these can be detected by
simple annoMatrix compare:
* schema is the same, including all fields and columns
* dimensionality is the same (nObs, nVar)
* data mapping/transformation, such as clipping, are the same
AnnoMatrixes also "stack" like filters, allowing for the construction of
views which transform the data in some manner.
The bootstrap class is AnnoMatrixLoader, which is the caching server proxy, and
is bootstrapped with a API URL:
new AnnoMatirx(url, schema) -> annoMatrix
There are various "views", such as AnnoMatrixRowSubsetView, which provide
the same interface but with a transformed view of the server data. Utilities in
viewCreators.js can be used to create these views:
clip(annoMatrix, min, max) -> annoMatrix
subset(annoMatrix, rowLabels) -> annoMatrix
etc.
*/
static fields() {
/*
return the fields present in the AnnoMatrix instance.
*/
return ["obs", "var", "emb", "X"];
}
constructor(schema, nObs, nVar, rowIndex = null) {
/*
Private constructor - this is an abstract base class. Do not use.
*/
/*
Public instance fields:
* schema - the matrix schema. IMPORTANT: always the entire schema, for the
base (unfiltered, unclipped, unsubset) annotated matrix, as the server
presents it.
* nObs, nVar - size of each dimension. These will accurately reflect the
size of the current annoMatrix view. For example, if you subset the view,
the nObs will be smaller.
* rowIndex - a rowIndex shared by all data on this view (ie, the list of cells).
The row index labels are as defined by the base dataset from the server.
* isView - true if this is a view, false if not.
* viewOf - pointer to parent annomatrix if a view, undefined/null if not a view.
* userFlags - container for any additional state a user of this API wants to hang
off of an annoMatrix, and have propagated by the (shallow) cloning protocol.
*/
this.schema = indexEntireSchema(schema);
this.nObs = nObs;
this.nVar = nVar;
this.rowIndex = rowIndex || new IdentityInt32Index(nObs);
this.isView = false;
this.viewOf = undefined;
this.userFlags = {};
/*
Private instance variables.
These are caches - lazily loaded. The only guarantee is that if they
are loaded, they will conform to the schema & dimensionality constraints.
Do NOT use directly - instead, use the fetch() and preload() API.
*/
this._cache = {
obs: Dataframe.empty(this.rowIndex),
var: Dataframe.empty(this.rowIndex),
emb: Dataframe.empty(this.rowIndex),
X: Dataframe.empty(this.rowIndex),
};
this._pendingLoad = {
obs: {},
var: {},
emb: {},
X: {},
};
this._whereCache = {};
this._gcInfo = new Map();
}
/**
** Schema helper/accessors
**/
getMatrixColumns(field) {
/*
Return array of column names in the field. ONLY supported on the
obs, var and emb fields. X currently unimplemented and will throw.
For exmaple:
annoMatrix.getMatrixColumns("obs") -> ["louvain", "n_genes"]
*/
return _schemaColumns(this.schema, field);
}
// eslint-disable-next-line class-methods-use-this -- need to be able to call this on instances
getMatrixFields() {
/*
Return array of fields in this annoMatrix. Currently hard-wired to
return: ["X", "obs", "var", "emb"].
These are the fields from data may be requested.
*/
return AnnoMatrix.fields();
}
getColumnSchema(field, col) {
/*
Return the schema for the field & column ,eg,
anonMatrix.getColumnSchema("obs", "n_genes") -> { type: "int32", name: "n_genes" }
This is identical to the information in the annoMatrix.schema
instance variable.
*/
return _getColumnSchema(this.schema, field, col);
}
getColumnDimensions(field, col) {
/*
Return the dimensions on this field / column. For most fields, which are 1D,
this just return the column name. Multi-dimensional columns, such as embeddings,
will return >1 name.
Examples:
getColumnDimensions("obs", "louvain") -> ["louvain"]
getColumnDimensions("emb", "umap") -> ["umap_0", "umap_1"]
*/
return _getColumnDimensionNames(this.schema, field, col);
}
/**
** General utility methods
**/
base() {
/*
return the base of view, or `this` if not a view.
*/
let annoMatrix = this;
while (annoMatrix.isView) annoMatrix = annoMatrix.viewOf;
return annoMatrix;
}
/**
** Load / read interfaces
**/
fetch(field, q) {
/*
Return the given query on a single matrix field as a single dataframe.
Currently supports ONLY full column query.
Returns a Promise for the query result, which will resolve to a dataframe.
Field must be one of the matrix fields: 'obs', 'var', 'X', 'emb'. Value
represents the underlying object upon which the query is occurring.
Query is one of:
* a string, representing a single column name from the field, eg,
"n_genes"
* an object, containing an "value" query (see below).
* an array, containing one or more of the above.
Columns may have more than one dimension, and all will be fetched
and returned together. This is most commonly seen in an embedding,
which usually has two dimensions.
A value query allows for fetching based upon the value in another
field/column, similar to a join. Currently only supported on the var
dimension, allowing query of X columns by var value (eg, gene name)
Examples:
1. Fetch the "n_genes" column the "obs":
const df = await fetch("obs", "n_genes")
console.log("Largest number of genes is: ", df.summarize().max);
2. Fetch two separate columns from obs. Returns a single dataframe containing
the columns:
const df = await fetch("obs", ["n_genes", "louvain"])
console.log("Cell 0 has category: ", df.at(0, "louvain"));
3. Fetch an entire X (expression counts) column that has a var annotation
value "TYMP" in the var index.
fetch("X", {
where: {
field: "var", column: this.schema.annotations.var.index, value: "TYMP"
}
})
In AnnData & Pandas DataFrame API, this is equivalent to:
adata.X[:, adata.var.index.get_loc("SUMO3")]
The value query is a recodification and subset of the server REST API
value filter JSON. Range queries and multiple filters are not currently
supported.
*/
return this._fetch(field, q);
}
prefetch(field, q) {
/*
Start a data fetch & cache fill. Identical to fetch() except it does
not return a value.
Primary use is to being a cache load as early as is possible, reducing
overall component rendering latency.
*/
this._fetch(field, q);
return undefined;
}
/**
** Save / mutate interfaces - manipulation of "writable" OBS annotations.
**
** These are all present to support client-side creation of OBS annotations, aka
** "user annotations".
**
** They implement common manipulations to the AnnoMatrix, maintaining the
** norma guarantees around correctness of public API, eg,
** - schema will be correct, including the "writable" attribute
** - fetch() will return the latest data, even from views
** - immutability guranteeds
**
** As most of these interfaces mutate the annoMatrix, they return a new
** annoMatrix
**
** The actual implementation is in the sub-classes, which MUST override these.
**/
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
addObsAnnoCategory(col, category) {
/*
Add a new category value (aka "label") to a writable obs column, and return the new AnnoMatrix.
Typical use is to add a new user-created label to a user-created obs categorical
annotation.
Will throw column does not exist or is not writable.
Example:
addObsAnnoCategory("my cell type", "left toenail") -> AnnoMatrix
*/
_subclassResponsibility();
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
async removeObsAnnoCategory(col, category, unassignedCategory) {
/*
Remove a category value from an obs column, reassign any obs having that value
to the 'unassignedCategory' value, and return a promise for a new AnnoMatrix.
Typical use is to remove a user-created label from a user-created obs categorical
annotation.
Will throw column does not exist or is not writable.
An `unassignedCategory` value must be provided, for assignment to any obs/cells
that had the now-delete category label as their value.
Example:
await removeObsAnnoCategory("my-tissue-type", "right earlobe", "unassigned") -> AnnoMatrix
NOTE: method is async as it may need to fetch data to provide the reassignment.
*/
_subclassResponsibility();
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
dropObsColumn(col) {
/*
Drop an entire writable column, eg a user-created obs annotation. Typical use
is to provide the "Delete Category" implementation. Returns the new AnnoMatrix.
Will throw if not a writable annotation.
Will throw column does not exist or is not writable.
Example:
dropObsColumn("old annotations") -> AnnoMatrix
*/
_subclassResponsibility();
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
addObsColumn(colSchema, Ctor, value) {
/*
Add a new writable OBS annotation column, with the caller-specified schema, initial value
type and value.
Value may be any one of:
* an array of values
* a primitive type, including null or undefined.
If an array, length must be the same as 'this.nObs', and constructor must equal 'Ctor'.
If a primitive, 'Ctor' will be used to create the initial value, which will be filled
with 'value'.
Throws if the name specified in 'colSchema' duplicates an existing obs column.
Returns a new AnnoMatrix.
Examples:
addObsColumn(
{ name: "foo", type: "categorical", categories: "unassigned" },
Array,
"unassigned"
) -> AnnoMatrix
*/
_subclassResponsibility();
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
renameObsColumn(oldCol, newCol) {
/*
Rename the obs column 'oldCol' to have name 'newCol' and returns new AnnoMatrix.
Will throw column does not exist or is not writable, or if 'newCol' is not unique.
Example:
renameObsColumn('cell type', 'old cell type') -> AnnoMatrix.
*/
_subclassResponsibility();
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
async setObsColumnValues(col, obsLabels, value) {
/*
Set all obs with label in array 'obsLabels' to have 'value'. Typical use would be
to set a group of cells to have a label on a user-created categorical anntoation
(eg set all selected cells to have a label).
NOTE: async method, as it may need to fetch.
Will throw column does not exist or is not writable.
Example:
await setObsColmnValues("flavor", [383, 400], "tasty") -> AnnoMtarix
*/
_subclassResponsibility();
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
async resetObsColumnValues(col, oldValue, newValue) {
/*
Set by value - all elements in the column with value 'oldValue' are set to 'newValue'.
Async method - returns a promise for a new AnnoMatrix.
Typical use would be to set all labels of one value to another.
Will throw column does not exist or is not writable.
Example:
await resetObsColumnValues("my notes", "good", "not-good") -> AnnoMatrix
*/
_subclassResponsibility();
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
addEmbedding(colSchema) {
/*
Add a new obs embedding to the AnnoMatrix, with provided schema.
Returns a new annomatrix.
Typical use will be to add a re-embedding that the server has calculated.
Will throw if the column schema is invalid (eg, duplicate name).
*/
_subclassResponsibility();
}
getCacheKeys(field, query) {
/*
Return cache keys for columns associated with this query. May return
[unknown] if no keys are known (ie, nothing is or was cached).
*/
return _whereCacheGet(this._whereCache, this.schema, field, query);
}
/**
** Private interfaces below.
**/
_resolveCachedQueries(field, queries) {
return queries
.map((query) =>
_whereCacheGet(this._whereCache, this.schema, field, query).filter(
(cacheKey) =>
cacheKey !== undefined && this._cache[field].hasCol(cacheKey)
)
)
.flat();
}
async _fetch(field, q) {
if (!AnnoMatrix.fields().includes(field)) return undefined;
const queries = Array.isArray(q) ? q : [q];
queries.forEach(_queryValidate);
/* find cached columns we need, and GC the rest */
const cachedColumns = this._resolveCachedQueries(field, queries);
this._gcFetchCleanup(field, cachedColumns);
/* find any query not already cached */
const uncachedQueries = queries.filter((query) =>
_whereCacheGet(this._whereCache, this.schema, field, query).some(
(cacheKey) =>
cacheKey === undefined || !this._cache[field].hasCol(cacheKey)
)
);
/* load uncached queries */
if (uncachedQueries.length > 0) {
await Promise.all(
uncachedQueries.map((query) =>
this._getPendingLoad(field, query, async (_field, _query) => {
/* fetch, then index. _doLoad is subclass interface */
const [whereCacheUpdate, df] = await this._doLoad(_field, _query);
this._cache[_field] = this._cache[_field].withColsFrom(df);
this._whereCache = _whereCacheMerge(
this._whereCache,
whereCacheUpdate
);
})
)
);
}
/* everything we need is in the cache, so just cherry-pick requested columns */
const requestedCacheKeys = this._resolveCachedQueries(field, queries);
const response = _dataframeCache(
this._cache[field].subset(null, requestedCacheKeys)
);
this._gcUpdateStats(field, response);
return response;
}
async _getPendingLoad(field, query, fetchFn) {
/*
Given a query on a field, ensure that we only have a single outstanding
fetch at any given time. If multiple requests occur while a fetch is
outstanding, just wait for the original.
This is implemented by returning a promise that will await the singular
fetch promise.
*/
const key = _queryCacheKey(field, query);
if (!this._pendingLoad[field][key]) {
this._pendingLoad[field][key] = fetchFn(field, query);
try {
await this._pendingLoad[field][key];
} finally {
delete this._pendingLoad[field][key];
}
}
return this._pendingLoad[field][key];
}
// eslint-disable-next-line class-methods-use-this -- make sure subclass implements
async _doLoad() {
_subclassResponsibility();
}
/**
** Garbage collection of annomatrix cache to manage memory use.
**/
/*
These callbacks implement a GC policy for the cache. Background:
* For the Loader (base) annomatrix, re-filling the cache is expensive as
it requires an HTTP fetch.
* user-defined / writable columns must not be GC'ed as they may be
still pending a save/commit.
* For views, cost is less and (roughly) proportional with nObs
* obs, var and emb do not grow without bounds, and are needed constantly
for rendering.
a) There is no upside to GC'ing these in the base (loader)
b) The undo/redo cache can hold a large number in views, which is worth GC'ing
* X is often much larger than memory, and the UI allows add/del from
this. Most of the GC potential is here in both the base and views.
Current policy:
* if in active use ("hot") do not GC obs, var or emb.
* never, ever GC writable obs columns
* For base/loader set a numeric limit on maximum X column count
* For views, apply a fixed limit to the number of columns cached in any field.
Limit will be lower if not hot.
To be effective, the GC callback needs to be invoked from the undo/redo code,
as much of the cache is pinned by that data structure.
*/
_gcField(field, isHot, pinnedColumns) {
const maxColumns = isHot ? 256 : 10; // maybe to aggressive?
const cache = this._cache[field];
if (cache.colIndex.size() < maxColumns) return; // trivial rejection
const candidates = cache.colIndex
.labels()
.filter((col) => !pinnedColumns.includes(col));
const excessCount = candidates.length + pinnedColumns.length - maxColumns;
if (excessCount > 0) {
const { _gcInfo } = this;
candidates.sort((a, b) => {
let atime = _gcInfo.get(_columnCacheKey(field, a));
if (atime === undefined) atime = 0;
let btime = _gcInfo.get(_columnCacheKey(field, b));
if (btime === undefined) btime = 0;
return atime - btime;
});
const toDrop = candidates.slice(0, excessCount);
// helpful debugging - please leave in place.
// console.log(
// `GC: dropping from ${field} hot:${isHot}, columns [${toDrop.join(
// ", "
// )}]`
// );
this._cache[field] = toDrop.reduce(
(df, col) => df.dropCol(col),
this._cache[field]
);
toDrop.forEach((col) => _gcInfo.delete(_columnCacheKey(field, col)));
}
}
_gcFetchCleanup(field, pinnedColumns) {
/*
Called during data load/fetch. By definition, this is 'hot', so we
only want to gc X.
*/
if (field === "X") {
this._gcField(
field,
true,
pinnedColumns.concat(_getWritableColumns(this.schema, field))
);
}
}
_gc(hints) {
/*
Called from middleware, or elsewhere. isHot is true if we are in the active store,
or false if we are in some other context (eg, history state).
*/
const { isHot } = hints;
const candidateFields = isHot ? ["X"] : ["X", "emb", "var", "obs"];
candidateFields.forEach((field) =>
this._gcField(field, isHot, _getWritableColumns(this.schema, field))
);
}
_gcUpdateStats(field, dataframe) {
/*
called each time a query is performed, allowing the gc to update any bookkeeping
information. Currently, this is just a simple last-fetched timestamp, stored
in a Map.
*/
const cols = dataframe.colIndex.labels();
const { _gcInfo } = this;
const now = Date.now();
cols.forEach((c) => {
_gcInfo.set(_columnCacheKey(field, c), now);
});
}
/**
Cloning subclass protocol - we rely in cloning to preserve immutable
semantics while not causing races or other side effects in internal
cache management.
Subclasses must override _cloneDeeper() if they have state which requires
something other than a shallow copy. Overrides MUST call super()._cloneDeepr(),
and return its result (after any required modification). _cloneDeeper()
will be called on the OLD object, with the NEW object as an argument.
Do not override _clone();
**/
_cloneDeeper(clone) {
clone._cache = _shallowClone(this._cache);
clone._gcInfo = new Map();
clone._pendingLoad = {
obs: {},
var: {},
emb: {},
X: {},
};
return clone;
}
_clone() {
const clone = _shallowClone(this);
this._cloneDeeper(clone);
Object.seal(clone);
return clone;
}
}
/*
private utility functions below
*/
function _columnCacheKey(field, column) {
return `${field}/${column}`;
}
function _subclassResponsibility() {
/* protect against bugs in subclass */
throw new Error("subclass failed to implement required method");
}
+6
View File
@@ -0,0 +1,6 @@
/*
Shallow clone an object, correctly handling prototype
*/
export default function _shallowClone(orig) {
return Object.assign(Object.create(Object.getPrototypeOf(orig)), orig);
}
+284
View File
@@ -0,0 +1,284 @@
/*
Row crossfilter proxy for an AnnoMatrix. This wraps Crossfilter,
providing a number of services, and ensuring that the crossfilter and
AnnoMatrix stay in sync:
- on-demand index creation as data is loaded
- transparently mapping between queries and crossfilter index names.
- for mutation of the matrix by user annotations, maintain synchronization
between Crossfilter and AnnoMatrix.
*/
import Crossfilter from "../util/typedCrossfilter";
import { _getColumnSchema } from "./schema";
function _dimensionNameFromDf(field, df) {
const colNames = df.colIndex.labels();
return _dimensionName(field, colNames);
}
function _dimensionName(field, colNames) {
if (!Array.isArray(colNames)) return `${field}/${colNames}`;
return `${field}/${colNames.join(":")}`;
}
export default class AnnoMatrixObsCrossfilter {
constructor(annoMatrix, _obsCrossfilter = null) {
this.annoMatrix = annoMatrix;
this.obsCrossfilter =
_obsCrossfilter || new Crossfilter(annoMatrix._cache.obs);
this.obsCrossfilter = this.obsCrossfilter.setData(annoMatrix._cache.obs);
}
size() {
return this.obsCrossfilter.size();
}
/**
Managing the associated annoMatrix. These wrappers are necessary to
make coordinated changes to BOTH the crossfilter and annoMatrix, and
ensure that all state stays synchronized.
See API documentation in annoMatrix.js.
**/
addObsColumn(colSchema, Ctor, value) {
const annoMatrix = this.annoMatrix.addObsColumn(colSchema, Ctor, value);
const obsCrossfilter = this.obsCrossfilter.setData(annoMatrix._cache.obs);
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
dropObsColumn(col) {
const annoMatrix = this.annoMatrix.dropObsColumn(col);
let { obsCrossfilter } = this;
const dimName = _dimensionName("obs", col);
if (obsCrossfilter.hasDimension(dimName)) {
obsCrossfilter = obsCrossfilter.delDimension(dimName);
}
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
renameObsColumn(oldCol, newCol) {
const annoMatrix = this.annoMatrix.renameObsColumn(oldCol, newCol);
const oldDimName = _dimensionName("obs", oldCol);
const newDimName = _dimensionName("obs", newCol);
let { obsCrossfilter } = this;
if (obsCrossfilter.hasDimension(oldDimName)) {
obsCrossfilter = obsCrossfilter.renameDimension(oldDimName, newDimName);
}
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
addObsAnnoCategory(col, category) {
const annoMatrix = this.annoMatrix.addObsAnnoCategory(col, category);
const dimName = _dimensionName("obs", col);
let { obsCrossfilter } = this;
if (obsCrossfilter.hasDimension(dimName)) {
obsCrossfilter = obsCrossfilter.delDimension(dimName);
}
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
async removeObsAnnoCategory(col, category, unassignedCategory) {
const annoMatrix = await this.annoMatrix.removeObsAnnoCategory(
col,
category,
unassignedCategory
);
const dimName = _dimensionName("obs", col);
let { obsCrossfilter } = this;
if (obsCrossfilter.hasDimension(dimName)) {
obsCrossfilter = obsCrossfilter.delDimension(dimName);
}
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
async setObsColumnValues(col, rowLabels, value) {
const annoMatrix = await this.annoMatrix.setObsColumnValues(
col,
rowLabels,
value
);
const dimName = _dimensionName("obs", col);
let { obsCrossfilter } = this;
if (obsCrossfilter.hasDimension(dimName)) {
obsCrossfilter = obsCrossfilter.delDimension(dimName);
}
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
async resetObsColumnValues(col, oldValue, newValue) {
const annoMatrix = await this.annoMatrix.resetObsColumnValues(
col,
oldValue,
newValue
);
const dimName = _dimensionName("obs", col);
let { obsCrossfilter } = this;
if (obsCrossfilter.hasDimension(dimName)) {
obsCrossfilter = obsCrossfilter.delDimension(dimName);
}
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
addEmbedding(colSchema) {
const annoMatrix = this.annoMatrix.addEmbedding(colSchema);
return new AnnoMatrixObsCrossfilter(annoMatrix, this.obsCrossfilter);
}
/**
* Drop the crossfilter dimension. Do not change the annoMatrix. Useful when we
* want to stop trackin the selection state, but aren't sure we want to blow the
* annomatrix cache.
*/
dropDimension(field, query) {
const { annoMatrix } = this;
let { obsCrossfilter } = this;
const keys = annoMatrix
.getCacheKeys(field, query)
.filter((k) => k !== undefined);
const dimName = _dimensionName(field, keys);
if (obsCrossfilter.hasDimension(dimName)) {
obsCrossfilter = obsCrossfilter.delDimension(dimName);
}
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
/**
Selection state - API is identical to ImmutableTypedCrossfilter, as these
are just wrappers to lazy create indices.
**/
async select(field, query, spec) {
const { annoMatrix } = this;
let { obsCrossfilter } = this;
if (!annoMatrix?._cache?.[field]) {
throw new Error("Unknown field name");
}
if (field === "var") {
throw new Error("unable to obsSelect upon the var dimension");
}
// grab the data, so we can grab the index.
const df = await annoMatrix.fetch(field, query);
const dimName = _dimensionNameFromDf(field, df);
if (!obsCrossfilter.hasDimension(dimName)) {
// lazy index generation - add dimension when first used
obsCrossfilter = this._addObsCrossfilterDimension(
annoMatrix,
obsCrossfilter,
field,
df
);
}
// select
obsCrossfilter = obsCrossfilter.select(dimName, spec);
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
selectAll() {
/*
Select all on any dimension in this field.
*/
const { annoMatrix } = this;
const currentDims = this.obsCrossfilter.dimensionNames();
const obsCrossfilter = currentDims.reduce((xfltr, dim) => xfltr.select(dim, { mode: "all" }), this.obsCrossfilter);
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
countSelected() {
/* if no data yet indexed in the crossfilter, just say everything is selected */
if (this.obsCrossfilter.size() === 0) return this.annoMatrix.nObs;
return this.obsCrossfilter.countSelected();
}
allSelectedMask() {
/* if no data yet indexed in the crossfilter, just say everything is selected */
if (
this.obsCrossfilter.size() === 0 ||
this.obsCrossfilter.dimensionNames().length === 0
) {
/* fake the mask */
return new Uint8Array(this.annoMatrix.nObs).fill(1);
}
return this.obsCrossfilter.allSelectedMask();
}
allSelectedLabels() {
/* if no data yet indexed in the crossfilter, just say everything is selected */
if (
this.obsCrossfilter.size() === 0 ||
this.obsCrossfilter.dimensionNames().length === 0
) {
return this.annoMatrix.rowIndex.labels();
}
const mask = this.obsCrossfilter.allSelectedMask();
const index = this.annoMatrix.rowIndex.isubsetMask(mask);
return index.labels();
}
fillByIsSelected(array, selectedValue, deselectedValue) {
/* if no data yet indexed in the crossfilter, just say everything is selected */
if (
this.obsCrossfilter.size() === 0 ||
this.obsCrossfilter.dimensionNames().length === 0
) {
return array.fill(selectedValue);
}
return this.obsCrossfilter.fillByIsSelected(
array,
selectedValue,
deselectedValue
);
}
/**
** Private below
**/
_addObsCrossfilterDimension(annoMatrix, obsCrossfilter, field, df) {
if (field === "var") return obsCrossfilter;
const dimName = _dimensionNameFromDf(field, df);
const dimParams = this._getObsDimensionParams(field, df);
obsCrossfilter = obsCrossfilter.setData(annoMatrix._cache.obs);
obsCrossfilter = obsCrossfilter.addDimension(dimName, ...dimParams);
return obsCrossfilter;
}
_getColumnBaseType(field, col) {
/* Look up the primitive type for this field/col */
const colSchema = _getColumnSchema(this.annoMatrix.schema, field, col);
return colSchema.type;
}
_getObsDimensionParams(field, df) {
/* return the crossfilter dimensiontype type and params for this field/dataframe */
if (field === "emb") {
/* assumed to be 2D */
return ["spatial", df.icol(0).asArray(), df.icol(1).asArray()];
}
/* assumed to be 1D */
const col = df.icol(0);
const colName = df.colIndex.getLabel(0);
const type = this._getColumnBaseType(field, colName);
if (type === "string" || type === "categorical" || type === "boolean") {
return ["enum", col.asArray()];
}
if (type === "int32") {
return ["scalar", col.asArray(), Int32Array];
}
if (type === "float32") {
return ["scalar", col.asArray(), Float32Array];
}
// Currently not supporting boolean and categorical types.
console.error(
`Warning - unknown metadata schema (${type}) for field ${field} ${colName}.`
);
// skip it - we don't know what to do with this type
return undefined;
}
}
+25
View File
@@ -0,0 +1,25 @@
export { doBinaryRequest, doFetch } from "../util/actionHelpers";
/* double URI encode - needed for query-param filters */
export function _dubEncURIComp(s) {
return encodeURIComponent(encodeURIComponent(s));
}
/* currently unused, consider deleting */
export function _fetchResult(promise) {
let _status = "pending";
const res = promise.then(
(r) => {
_status = "success";
return r;
},
(e) => {
_status = "error";
throw e;
}
);
res.status = () => _status;
return res;
}

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