mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 21:38:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcb1e988ad | ||
|
|
a7bd76f676 | ||
|
|
c12cb2424a | ||
|
|
1471d6b214 | ||
|
|
98b63fa9ea | ||
|
|
0aa0f641ab | ||
|
|
77a4495b28 | ||
|
|
b521a17ffd | ||
|
|
86bf64e793 | ||
|
|
8a72010768 | ||
|
|
a08e19bbd0 | ||
|
|
9f10d8095a | ||
|
|
ea187f48e0 | ||
|
|
9c6273eb94 | ||
|
|
1f735abe2b | ||
|
|
b878b0f93c | ||
|
|
ad0a3c939c | ||
|
|
d581a0d460 | ||
|
|
9044b8d85d | ||
|
|
73852dd6f4 | ||
|
|
5e02408732 | ||
|
|
34e5a91dc6 | ||
|
|
7275d9d4dc | ||
|
|
c9a8e3ea42 | ||
|
|
71505abe4c | ||
|
|
c9a56fa73a | ||
|
|
3167e38993 | ||
|
|
ce8326ddea | ||
|
|
5d4b11d287 | ||
|
|
096d477893 | ||
|
|
153f240c43 | ||
|
|
df46db965a | ||
|
|
7ef5203564 | ||
|
|
9420abfacc | ||
|
|
16f93397ae | ||
|
|
016a4a422a | ||
|
|
996b06cecc | ||
|
|
571b7387e7 | ||
|
|
b6d468376a | ||
|
|
d8fc7e40a1 | ||
|
|
e875ed739b | ||
|
|
aa5ce4a2f1 | ||
|
|
80969012c9 | ||
|
|
caaee7e9bf | ||
|
|
f96fd36ecb | ||
|
|
495dc55144 | ||
|
|
8795f0f32c | ||
|
|
a0f54b4871 | ||
|
|
68dfbcc2eb | ||
|
|
82493d1019 | ||
|
|
3d6df0c044 | ||
|
|
2583016693 | ||
|
|
d8e3721846 | ||
|
|
a876740a3c | ||
|
|
e7ad6f5d1c | ||
|
|
2f1facaafb | ||
|
|
ffd6273419 | ||
|
|
2bae696986 | ||
|
|
6b33315cbe | ||
|
|
57c4e9ff33 | ||
|
|
4e67c645f8 | ||
|
|
8b28d51dfa | ||
|
|
40ad283107 | ||
|
|
0f8d7a55de | ||
|
|
b6f946ec8a | ||
|
|
dbb3a309a9 | ||
|
|
2e9525741f | ||
|
|
585a5808b9 | ||
|
|
6f464f4f92 | ||
|
|
08ea7d5137 | ||
|
|
ad9be3cdd7 | ||
|
|
f737cc4ee4 | ||
|
|
1103272b95 | ||
|
|
2df7161cd8 | ||
|
|
d31c05c970 | ||
|
|
07db2eb3ee | ||
|
|
a6d2a2e119 | ||
|
|
f87e4bfbd3 | ||
|
|
ecaa32cfb2 | ||
|
|
10693b08cc | ||
|
|
b02361a6bf | ||
|
|
3fa888c6d4 | ||
|
|
a42c9aca1c | ||
|
|
394da40bea | ||
|
|
5d60505407 | ||
|
|
f876a0091a | ||
|
|
528eb5d172 | ||
|
|
b90447c387 | ||
|
|
42e25a1a1f | ||
|
|
eceab377f7 | ||
|
|
b0daeb3a9b | ||
|
|
7499fca251 | ||
|
|
05a5a945bb | ||
|
|
d3c96087b2 | ||
|
|
cfcf16aa69 | ||
|
|
bc59074300 | ||
|
|
56f9bc543e | ||
|
|
f2eb2cad82 | ||
|
|
bd523280a4 | ||
|
|
fc45fb3899 | ||
|
|
f0f7200f0b | ||
|
|
6af7708d62 | ||
|
|
a297ea30e2 | ||
|
|
0ef6c36f4c | ||
|
|
aaa60bc303 | ||
|
|
7d40d89fd8 | ||
|
|
46ad346df1 | ||
|
|
83154577e4 | ||
|
|
a847951658 | ||
|
|
24af6efbcb | ||
|
|
126cac833a | ||
|
|
fc272dc42e | ||
|
|
d3a0d66139 | ||
|
|
9604231a2a | ||
|
|
6ea3b7f3cf | ||
|
|
0d0a32f272 | ||
|
|
48e0ea542b | ||
|
|
95fd20b405 | ||
|
|
5c6cc597c8 | ||
|
|
3bfeadc2b9 | ||
|
|
296ed752fa | ||
|
|
1e66ec2b89 | ||
|
|
4a50f0ed46 | ||
|
|
af0d1f6fb2 | ||
|
|
a83ec60308 | ||
|
|
76c0cbf16b | ||
|
|
455d987398 | ||
|
|
00b9a8fa72 | ||
|
|
28f5263c88 | ||
|
|
933fac5fff | ||
|
|
0c26f227fe | ||
|
|
2b90c747f5 | ||
|
|
43a4e087ef | ||
|
|
f2612707bb | ||
|
|
138d30909a | ||
|
|
2b5094665f | ||
|
|
a81258bc0d | ||
|
|
141f802824 | ||
|
|
3475f3f12e | ||
|
|
dff2526077 | ||
|
|
2b4aa92f67 | ||
|
|
3151306d7e | ||
|
|
ef80c8df2a | ||
|
|
1a5f49239a | ||
|
|
dfc04d8de5 | ||
|
|
523048de43 | ||
|
|
07cda497d9 | ||
|
|
00a68276a2 | ||
|
|
bc0cecbd1c | ||
|
|
dbff824854 | ||
|
|
9decf134e0 | ||
|
|
47ce0cfc49 | ||
|
|
018e3ae5c4 | ||
|
|
aa29c91470 | ||
|
|
dd56d0937c | ||
|
|
dfcef6075e | ||
|
|
b4edd56735 | ||
|
|
1dc80145dc | ||
|
|
6916aec6c3 | ||
|
|
9f7e1f1cb0 | ||
|
|
121360c0a5 | ||
|
|
7d5c054b90 | ||
|
|
a9189017c7 | ||
|
|
2d5bc9d0c9 | ||
|
|
24a8a23355 | ||
|
|
0dcc17d6d5 | ||
|
|
e2d864a336 | ||
|
|
0c8a07ac13 | ||
|
|
6e46a65c32 | ||
|
|
9d7754e31e | ||
|
|
b181751493 | ||
|
|
01369801fc | ||
|
|
bc15e9229f | ||
|
|
4f888505ee | ||
|
|
26d222cd75 | ||
|
|
75ca14e4aa | ||
|
|
ed66ae6e4a | ||
|
|
6c23a72e5f | ||
|
|
16b0741a6c | ||
|
|
d221d76be6 | ||
|
|
94f95d6565 | ||
|
|
02b2349807 | ||
|
|
9e5114a730 | ||
|
|
85f44ccabf | ||
|
|
7aa390b5a1 | ||
|
|
3556bd11f5 | ||
|
|
4ec938c166 | ||
|
|
b74c9e15c6 | ||
|
|
96675f806c | ||
|
|
df671192be | ||
|
|
0c271bc815 | ||
|
|
efb55a6332 | ||
|
|
2a3577f32b | ||
|
|
5dea303bcd | ||
|
|
06f402ab0a | ||
|
|
2616b5c185 | ||
|
|
f33a3cd1a3 | ||
|
|
7c8b89eba3 | ||
|
|
f9f43c42c0 | ||
|
|
952578680a | ||
|
|
76ec29734a | ||
|
|
6b0aafd27c | ||
|
|
65f5244ff7 | ||
|
|
e7642e6a8f | ||
|
|
8114d7565a | ||
|
|
8e08534d42 | ||
|
|
2b77f8146b | ||
|
|
e1a437514e | ||
|
|
48fa221ad9 | ||
|
|
fff331e1f0 | ||
|
|
8d07b400be | ||
|
|
5b2c07f105 | ||
|
|
ca35ae0b40 | ||
|
|
1cf9fc76b0 | ||
|
|
a42f9f83f5 | ||
|
|
9de44e6fd4 | ||
|
|
ca37483447 | ||
|
|
dd1b9229fc | ||
|
|
73f55cb9f8 | ||
|
|
8aa75458ed | ||
|
|
0c8f4ed824 | ||
|
|
5165297e2a | ||
|
|
9c8e0e76ad | ||
|
|
693dc7be12 | ||
|
|
090bad040e | ||
|
|
a297956e69 | ||
|
|
b0603dbed7 | ||
|
|
30069c0e66 | ||
|
|
2c74291ee9 | ||
|
|
329cf64a49 | ||
|
|
c7e6f54450 | ||
|
|
bf0154ada6 | ||
|
|
dcb3f6e901 | ||
|
|
2e9d576890 | ||
|
|
3229babd14 | ||
|
|
b90292ce4c | ||
|
|
210787cb5d | ||
|
|
ee0b066eb6 | ||
|
|
ab76c57fdb | ||
|
|
b77d7b14b5 | ||
|
|
aea934bb6d | ||
|
|
eb3b3fc59a | ||
|
|
dce778f658 | ||
|
|
11ef5ae51b | ||
|
|
8be0833d23 | ||
|
|
a0aae2e266 | ||
|
|
19f2728a18 | ||
|
|
8674ad1f7f | ||
|
|
187bbfdcf7 | ||
|
|
467ae94faf | ||
|
|
d09c820c1b | ||
|
|
67fc20b69a | ||
|
|
eeec842ad0 | ||
|
|
f0d9d873be | ||
|
|
2d218fa099 | ||
|
|
023fd01957 | ||
|
|
7a39642593 | ||
|
|
967ea21d8a | ||
|
|
4543b002e3 | ||
|
|
d9a75fcd10 | ||
|
|
9ee8781f32 | ||
|
|
b2c42b6379 | ||
|
|
f8b00af011 | ||
|
|
d4e850d18f | ||
|
|
7b00fea44d | ||
|
|
18e1b0f2fb | ||
|
|
f7abee5156 | ||
|
|
fddf114076 | ||
|
|
d550b5503d | ||
|
|
743eee3d78 | ||
|
|
d492e129d0 | ||
|
|
6665f55075 | ||
|
|
44ef1af863 | ||
|
|
6fd7f23364 | ||
|
|
3c9de7be9e | ||
|
|
3087c173e6 | ||
|
|
e4cd873b21 | ||
|
|
67bf7491c6 | ||
|
|
531e60caa4 | ||
|
|
f8bf32656c | ||
|
|
a7f728a9e8 | ||
|
|
1712114c9d | ||
|
|
39414503bd | ||
|
|
451c930cb5 | ||
|
|
b2619354c5 | ||
|
|
fbf798f4c4 | ||
|
|
de5a6b1d29 | ||
|
|
831ef283a0 | ||
|
|
113cf17183 | ||
|
|
a03f4bcdd9 | ||
|
|
34561a3b08 | ||
|
|
4e80ed6c5d | ||
|
|
73b6658d29 | ||
|
|
2cda3fb68f | ||
|
|
2bf8b273d3 | ||
|
|
563b1c34c9 | ||
|
|
985cc0de94 | ||
|
|
c57d648f15 | ||
|
|
33ce77b669 | ||
|
|
9d680cf8d8 | ||
|
|
b884487789 | ||
|
|
ddaa7016a5 | ||
|
|
a29ba8da4e | ||
|
|
463cdef08b | ||
|
|
c8c098282d | ||
|
|
8c88f752ea | ||
|
|
a4464d107f | ||
|
|
d44d267bda | ||
|
|
55fa9b892a | ||
|
|
5917dd1fb4 | ||
|
|
21a1f00a64 | ||
|
|
e96653b826 | ||
|
|
d01792f22b | ||
|
|
58000c1815 | ||
|
|
4417e8f3da | ||
|
|
3961bdc46d | ||
|
|
004303300b | ||
|
|
ba1e26138f | ||
|
|
d093dc2e3b | ||
|
|
3f0bb2ce1d | ||
|
|
585d51e9cf | ||
|
|
246f4e41f0 | ||
|
|
67504dcaff | ||
|
|
af85792cce | ||
|
|
f4780a9806 | ||
|
|
8119962c28 | ||
|
|
b2add5f2ce | ||
|
|
2a11af2550 | ||
|
|
2b698cc7d0 | ||
|
|
e5c7c0c44a | ||
|
|
11f88a9eb2 | ||
|
|
bf19bb7dce | ||
|
|
c2b3f331cf | ||
|
|
499b9551f1 | ||
|
|
0969854e9e | ||
|
|
247b89ab0c | ||
|
|
736ad2ca20 | ||
|
|
77f6e918b4 | ||
|
|
ccee3e0a38 | ||
|
|
7ac8d31a96 | ||
|
|
c11ae77de4 | ||
|
|
c47f8d6ca5 | ||
|
|
10cc801197 | ||
|
|
0db24a6c07 | ||
|
|
9236f02637 | ||
|
|
957c1a9f20 | ||
|
|
12c52a55f7 | ||
|
|
16d7fac88e | ||
|
|
a2b699b05d | ||
|
|
12b8232327 | ||
|
|
a22a4e2473 | ||
|
|
00be4b710e | ||
|
|
8401d1dd3d | ||
|
|
3822d4b005 | ||
|
|
72afb3510c | ||
|
|
6bc94a53ce | ||
|
|
15923d4523 | ||
|
|
c69a730e12 | ||
|
|
ff07bb1ebb | ||
|
|
60238f61c4 | ||
|
|
8f56f0ce6e | ||
|
|
f839653c00 | ||
|
|
a39e0c79a6 | ||
|
|
0c8b86d1c4 | ||
|
|
dfea46eff0 | ||
|
|
76d5252506 | ||
|
|
cb6b784dfd | ||
|
|
e682028b8e | ||
|
|
77ca5c267e | ||
|
|
8dbb131afd | ||
|
|
64bfb7416e | ||
|
|
7ae1db627c | ||
|
|
ee5fe8f37b | ||
|
|
e11b905836 | ||
|
|
116cef551c | ||
|
|
993057b79c | ||
|
|
5400df25ca | ||
|
|
489a2dd5db | ||
|
|
0a36fac142 | ||
|
|
69fca7ea8b | ||
|
|
7642251d44 | ||
|
|
20331313e0 | ||
|
|
ee2b7fc13f | ||
|
|
78077ad566 | ||
|
|
0b84e9e804 | ||
|
|
b574542a60 | ||
|
|
516ecc2ad3 | ||
|
|
d77accc858 | ||
|
|
83e7685f57 | ||
|
|
de5e0f4ea7 | ||
|
|
4b2f24cb40 | ||
|
|
fec055ede3 | ||
|
|
fdd9b1d0bd | ||
|
|
9c72f2c2d5 | ||
|
|
2c7a11ab80 | ||
|
|
45f61d87d7 | ||
|
|
71d1cd250c | ||
|
|
558e8f522a | ||
|
|
895cfcebe0 | ||
|
|
cd7c06c822 | ||
|
|
8b0d7d5cf6 | ||
|
|
28d341cf1c | ||
|
|
59022d7a9c | ||
|
|
fdaaaf69a2 | ||
|
|
6961b64d87 | ||
|
|
a5423a6911 | ||
|
|
05b9a6833d | ||
|
|
b8f501a46c | ||
|
|
b6f253e843 | ||
|
|
851ddde130 | ||
|
|
2e1da27a31 | ||
|
|
d375c48b6a | ||
|
|
82c4d2948c | ||
|
|
5a15f5a915 | ||
|
|
8aafd7d271 | ||
|
|
02799cea8e | ||
|
|
ba9e5f1e41 | ||
|
|
cc354d7d61 | ||
|
|
03ec942723 | ||
|
|
dd019d83e5 | ||
|
|
2721e91788 | ||
|
|
2970189397 | ||
|
|
76446ca7d4 | ||
|
|
f466ef6553 | ||
|
|
a3bf802aec | ||
|
|
071c24d0f4 | ||
|
|
d937485034 | ||
|
|
0639759a90 | ||
|
|
558950852e | ||
|
|
cd1b67fd09 | ||
|
|
5b9ddfa1a6 | ||
|
|
354eacc3c8 | ||
|
|
cf12acd47c | ||
|
|
6652c07495 | ||
|
|
e3bf06ca61 | ||
|
|
01de23c533 | ||
|
|
4365b24212 | ||
|
|
aa6f38e0a3 | ||
|
|
451f95b0a4 | ||
|
|
013c5fb078 | ||
|
|
d1895ecb05 | ||
|
|
85506db971 | ||
|
|
e383e52320 | ||
|
|
2da62720dd | ||
|
|
56c932a443 | ||
|
|
40f5b94075 | ||
|
|
1eeaca41f9 | ||
|
|
7a9f0ef29e | ||
|
|
91902aa33e | ||
|
|
b24b71692c | ||
|
|
64223ff95e | ||
|
|
ba13e32206 | ||
|
|
a60b3ea296 | ||
|
|
dd5fa57259 | ||
|
|
59dcfe2bc4 | ||
|
|
c7f9dc0bb9 | ||
|
|
67f2e19f39 | ||
|
|
ca16447115 | ||
|
|
15ce4cc338 | ||
|
|
ee8bb510ff | ||
|
|
9b4bcb7f7b | ||
|
|
1621d20bc4 | ||
|
|
f96a03e58c | ||
|
|
f0e008eb66 | ||
|
|
a4e75fceba | ||
|
|
fabd3ca2ff | ||
|
|
2fb086b5ad | ||
|
|
d1d3353130 | ||
|
|
e6c912f6ad | ||
|
|
9cf300caeb | ||
|
|
7ecd61ce0f | ||
|
|
a64484a1dd | ||
|
|
ebb1bb93cf | ||
|
|
e84793ede9 | ||
|
|
c60904a145 | ||
|
|
a0d5f1bf86 | ||
|
|
9e8d48d630 | ||
|
|
0b50f02d55 | ||
|
|
a0c6e3102b | ||
|
|
37df1d7a67 | ||
|
|
2b70d165be | ||
|
|
bf6ebd5741 | ||
|
|
9c2870e995 | ||
|
|
1565f47f6e | ||
|
|
92dfc5f2ee | ||
|
|
eaa4e6c7a4 | ||
|
|
67e65e7535 | ||
|
|
20756764f9 | ||
|
|
2f23e8675f | ||
|
|
089d04ea9f | ||
|
|
cfd13cad41 | ||
|
|
2c79e98809 | ||
|
|
7874e6a68b | ||
|
|
007e1d3a66 | ||
|
|
cc661f31e1 |
@@ -0,0 +1,15 @@
|
|||||||
|
[bumpversion]
|
||||||
|
current_version = 0.9.2
|
||||||
|
|
||||||
|
[bumpversion:file:setup.py]
|
||||||
|
search = version="{current_version}"
|
||||||
|
replace = version="{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:server/cli/cli.py]
|
||||||
|
search = version="{current_version}"
|
||||||
|
replace = version="{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:client/package.json]
|
||||||
|
search = "version": "{current_version}"
|
||||||
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
bin
|
||||||
|
client
|
||||||
|
dist
|
||||||
|
docs
|
||||||
|
server
|
||||||
+31
-3
@@ -1,16 +1,44 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules/
|
||||||
|
|
||||||
# coverage
|
# coverage
|
||||||
coverage
|
coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
build
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
venv/
|
||||||
|
cellxgene/
|
||||||
|
|
||||||
|
# client build
|
||||||
|
server/app/web/static/css/
|
||||||
|
server/app/web/static/img/
|
||||||
|
server/app/web/static/media/
|
||||||
|
server/app/web/static/js/
|
||||||
|
server/app/web/templates/index\.html
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
*.ipynb
|
||||||
|
|
||||||
|
# cefpython
|
||||||
|
error.log
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
.vscode
|
.vscode
|
||||||
|
*.idea*
|
||||||
|
__pycache__
|
||||||
|
*.DS_Store*
|
||||||
data
|
data
|
||||||
|
|
||||||
|
# Jekyll
|
||||||
|
docs/_site/
|
||||||
|
docs/Gemfile.lock
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
language: python
|
||||||
|
dist: xenial
|
||||||
|
sudo: required
|
||||||
|
node_js:
|
||||||
|
- 8
|
||||||
|
cache:
|
||||||
|
- pip
|
||||||
|
- npm
|
||||||
|
install:
|
||||||
|
- set -eo pipefail
|
||||||
|
- pip install flake8
|
||||||
|
- make build
|
||||||
|
- make install
|
||||||
|
- pip install -r server/requirements-dev.txt
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
include:
|
||||||
|
- name: "Branch Tests 3.7"
|
||||||
|
python: "3.7"
|
||||||
|
script: ./travis-build.sh
|
||||||
|
- name: "Branch Tests 3.6"
|
||||||
|
python: "3.6"
|
||||||
|
script: ./travis-build.sh
|
||||||
|
- name: "Docker Build"
|
||||||
|
install: skip
|
||||||
|
python: "3.6"
|
||||||
|
script: docker build .
|
||||||
|
- name: "Smoke Tests"
|
||||||
|
python: "3.6"
|
||||||
|
if: branch = master AND type = cron
|
||||||
|
script:
|
||||||
|
- npm run --prefix client/ smoke-test
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
FROM ubuntu:bionic
|
||||||
|
|
||||||
|
ENV LC_ALL=C.UTF-8
|
||||||
|
ENV LANG=C.UTF-8
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev && \
|
||||||
|
pip3 install cellxgene
|
||||||
|
|
||||||
|
ENTRYPOINT ["cellxgene"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
recursive-include server/app/web/templates *
|
||||||
|
recursive-include server/app/web/static *
|
||||||
|
|
||||||
|
include server/requirements.txt
|
||||||
@@ -1,9 +1,114 @@
|
|||||||
# cellxgene
|
# cellxgene
|
||||||
|
|
||||||
A React + Redux web application for exploring large scale single cell RNA sequence data.
|
> an interactive explorer for single-cell transcriptomics data
|
||||||
|
|
||||||
##### Quickstart:
|
_cellxgene_ (pronounced "sell-by-jean") is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data, and to demonstrate general, scalable, and reusable patterns for scientific data visualization.
|
||||||
|
|
||||||
* `npm install`
|
<img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-1.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-2.gif" width="200" height="200" hspace="30"><img src="https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/docs/cellxgene-demo-3.gif" width="200" height="200" hspace="30">
|
||||||
* `npm start`
|
|
||||||
* `localhost:3000`
|
- Want to install and use cellxgene? Visit the [cellxgene docs](https://chanzuckerberg.github.io/cellxgene/).
|
||||||
|
- Want to see where we are going? Check out [our roadmap](ROADMAP.md).
|
||||||
|
- Want to contribute? See our [contributors guide](#Contributing)
|
||||||
|
|
||||||
|
## quick start
|
||||||
|
|
||||||
|
To install _cellxgene_ you need Python 3.6+. We recommend [installing _cellxgene_ into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/faq.html#how-do-i-create-a-python-36-environment-for-cellxgene)
|
||||||
|
|
||||||
|
Install the package.
|
||||||
|
``` bash
|
||||||
|
pip install cellxgene
|
||||||
|
```
|
||||||
|
|
||||||
|
Download an example [anndata](https://anndata.readthedocs.io/en/latest/) file
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
curl -o pbmc3k.h5ad https://raw.githubusercontent.com/chanzuckerberg/cellxgene/master/example-dataset/pbmc3k.h5ad
|
||||||
|
```
|
||||||
|
|
||||||
|
Launch _cellxgene_
|
||||||
|
``` bash
|
||||||
|
cellxgene launch pbmc3k.h5ad --open
|
||||||
|
```
|
||||||
|
|
||||||
|
To learn more about what you can do with _cellxgene_, see the [Getting Started](https://chanzuckerberg.github.io/cellxgene/getting-stared/) guide.
|
||||||
|
|
||||||
|
## get in touch
|
||||||
|
|
||||||
|
Have questions, suggestions, or comments? You can come hang out with us by joining the [CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and posting in the `#cellxgene-users` channel. As mentioned above, please submit any feature requests or bugs as [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). We'd love to hear from you!
|
||||||
|
|
||||||
|
## where we are going
|
||||||
|
|
||||||
|
Our goal is to enable teams of computational and experimental
|
||||||
|
biologists to collaboratively gain insight into their single-cell RNA-seq data.
|
||||||
|
|
||||||
|
There are 4 key features we plan to implement in the near term.
|
||||||
|
|
||||||
|
- Click install and launch
|
||||||
|
- Manual annotation workflows
|
||||||
|
- Toggle embeddings
|
||||||
|
- Gene information
|
||||||
|
|
||||||
|
For more detail on these features and where we are going, see [our roadmap](ROADMAP.md).
|
||||||
|
|
||||||
|
## contributing
|
||||||
|
|
||||||
|
We warmly welcome contributions from the community! Please submit any bug reports and feature requests through [Github issues](https://github.com/chanzuckerberg/cellxgene/issues). Please submit any direct contributions by forking the repository, creating a branch, and submitting a Pull Request. It'd be great for PRs to include test cases and documentation updates where relevant, though we know the core test suite is itself still a work in progress. And all code contributions and dependencies must be compatible with the project's open-source license (MIT). If you have any questions about this stuff, just ask!
|
||||||
|
|
||||||
|
### developer guide
|
||||||
|
|
||||||
|
This project has made a few key design choices
|
||||||
|
|
||||||
|
- The front-end is built with [`regl`](https://github.com/regl-project/regl) (a webgl library), [`react`](https://reactjs.org/), [`redux`](https://redux.js.org/), [`d3`](https://github.com/d3/d3), and [`blueprint`](https://blueprintjs.com/docs/#core) to handle rendering large numbers of cells with lots of complex interactivity
|
||||||
|
- The app is designed with a client-server model that can support a range of existing analysis packages for backend computational tasks (currently built for [scanpy](https://github.com/theislab/scanpy))
|
||||||
|
- The client uses fast cross-filtering to handle selections and comparisons across subsets of data
|
||||||
|
|
||||||
|
Depending on your background and interests, you might want to contribute to the frontend, or backend, or both!
|
||||||
|
|
||||||
|
If you are interested in working on `cellxgene` development, we recommend cloning the project from Gitub. First you'll need the following installed on your machine
|
||||||
|
|
||||||
|
- python 3.6+
|
||||||
|
- node and npm (we recommend using [nvm](https://github.com/creationix/nvm) if this is your first time with node)
|
||||||
|
|
||||||
|
Then clone the project
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/chanzuckerberg/cellxgene.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the client web assets by calling `make` from inside the `cellxgene` folder
|
||||||
|
|
||||||
|
```
|
||||||
|
make
|
||||||
|
```
|
||||||
|
|
||||||
|
Install all requirements (we recommend doing this inside a virtual environment)
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
You can start the app while developing either by calling `cellxgene` or by calling `python -m server`. We recommend using the `--debug` flag to see more output, which you can include when reporting bugs.
|
||||||
|
|
||||||
|
If you have any questions about developing or contributing, come hang out with us by joining the [CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and posting in the `#cellxgene-dev` channel.
|
||||||
|
|
||||||
|
## inspiration
|
||||||
|
|
||||||
|
We've been heavily inspired by several other related single-cell visualization projects, including the [UCSC Cell Browswer](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [Gene Pattern](http://genepattern-notebook.org/), and many others. We hope to explore collaborations where useful as this community works together on improving interactive visualization for single-cell data.
|
||||||
|
|
||||||
|
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
|
||||||
|
|
||||||
|
We have been working closely with the [`scanpy`](https://github.com/theislab/scanpy) team to integrate with their awesome analysis tools. Special thanks to Alex Wolf, Fabian Theis, and the rest of the team for their help during development and for providing an example dataset.
|
||||||
|
|
||||||
|
We are eager to explore integrations with other computational backends such as [`Seurat`](https://github.com/satijalab/seurat) or [`Bioconductor`](https://github.com/Bioconductor)
|
||||||
|
|
||||||
|
## core team
|
||||||
|
|
||||||
|
- Colin Megill, frontend & product design
|
||||||
|
- Charlotte Weaver, software engineer
|
||||||
|
- Bruce Martin, software engineer
|
||||||
|
- Sidney Bell, computational biologist
|
||||||
|
- Justin Kiggins, product manager
|
||||||
|
|
||||||
|
## reuse
|
||||||
|
|
||||||
|
This project was started with the sole goal of empowering the scientific community to explore and understand their data. As such, we encourage other scientific tool builders in academia or industry to adopt the patterns, tools, and code from this project, and reach out to us with ideas or questions. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# cellxgene roadmap
|
||||||
|
|
||||||
|
We are very exited for _cellxgene_ to become a valuable tool in collaborations
|
||||||
|
between computational biologists and experimental biologists working on
|
||||||
|
single-cell transcriptomics data. _cellxgene_ is in active development, and we
|
||||||
|
would love to include the community as we plan new features to work on. If you
|
||||||
|
have questions of feedback about this roadmap, please submit an issue on
|
||||||
|
GitHub.
|
||||||
|
|
||||||
|
Please note: this roadmap is subject to change.
|
||||||
|
|
||||||
|
*Last updated: April 11, 2019*
|
||||||
|
|
||||||
|
## what we are building now
|
||||||
|
|
||||||
|
In the near term, our goal is to enable teams of computational and experimental
|
||||||
|
biologists to collaboratively explore and annotate their single-cell RNA-seq data.
|
||||||
|
|
||||||
|
There are 4 key features we plan to implement in the near term.
|
||||||
|
|
||||||
|
- Click install and launch
|
||||||
|
- Manual annotation workflows
|
||||||
|
- Toggle embeddings
|
||||||
|
- Gene information
|
||||||
|
|
||||||
|
### simple install and launch
|
||||||
|
|
||||||
|
The command line interface for installing and launching cellxgene is a barrier
|
||||||
|
for users who are not used to Python or using the command line. We plan to
|
||||||
|
support installation and launch of cellxgene on Mac and Windows. See
|
||||||
|
[Issue #687](https://github.com/chanzuckerberg/cellxgene/issues/687) for more details.
|
||||||
|
|
||||||
|
### manual annotation workflows
|
||||||
|
|
||||||
|
The exploratory visualization that cellxgene offers is critical for manual
|
||||||
|
annotation workflows, especially in collaborative environments. We plan to
|
||||||
|
support manually annotate cells with labels (i.e., cell type or QC flags) for
|
||||||
|
downstream analysis. See [Issue #524](https://github.com/chanzuckerberg/cellxgene/issues/524)
|
||||||
|
for more details.
|
||||||
|
|
||||||
|
### toggle embeddings
|
||||||
|
|
||||||
|
While a single dataset may have multiple embeddings calculated (tSNE, umap, in
|
||||||
|
situ coordinates, trajectories, etc), cellxgene currently requires the user to select the
|
||||||
|
embedding to use in the main layout at launch. We plan to support letting users
|
||||||
|
toggle between any embedding present in a file from the cellxgene interface.
|
||||||
|
See [Issue #594](https://github.com/chanzuckerberg/cellxgene/issues/594) for details.
|
||||||
|
|
||||||
|
### gene information
|
||||||
|
|
||||||
|
Differential expression returns only the names of genes, but no additional information
|
||||||
|
about gene metadata, function, or known associations. We plan to help users learn
|
||||||
|
more about genes they discover by exposing additional gene metadata. See
|
||||||
|
[Issue #96](https://github.com/chanzuckerberg/cellxgene/issues/96) for details.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const jest_env = process.env.JEST_ENV || "dev";
|
||||||
|
export const appPort = process.env.JEST_CXG_PORT || 3000;
|
||||||
|
export const appUrlBase = `http://localhost:${appPort}`;
|
||||||
|
export const DEV = jest_env === "dev";
|
||||||
|
export const DEBUG = jest_env === "debug";
|
||||||
|
export const DATASET = "pbmc3k";
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export const datasets = {
|
||||||
|
pbmc3k: {
|
||||||
|
title: "cellxgene: pbmc3k",
|
||||||
|
dataframe: {
|
||||||
|
nObs: "2638",
|
||||||
|
nVar: "1838",
|
||||||
|
type: "float32"
|
||||||
|
},
|
||||||
|
categorical: {
|
||||||
|
louvain: {
|
||||||
|
"B cells": "342",
|
||||||
|
"CD14+ Monocytes": "480",
|
||||||
|
"CD4 T cells": "1144",
|
||||||
|
"CD8 T cells": "316",
|
||||||
|
"Dendritic cells": "37",
|
||||||
|
"FCGR3A+ Monocytes": "150",
|
||||||
|
Megakaryocytes: "15",
|
||||||
|
"NK cells": "154"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
continuous: {
|
||||||
|
n_genes: "int32",
|
||||||
|
percent_mito: "float32",
|
||||||
|
n_counts: "float32"
|
||||||
|
},
|
||||||
|
cellsets: {
|
||||||
|
lasso: [
|
||||||
|
{
|
||||||
|
"coordinates-as-percent": { x1: 0.25, y1: 0.25, x2: 0.35, y2: 0.35 },
|
||||||
|
count: "26"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
categorical: [
|
||||||
|
{
|
||||||
|
metadata: "louvain",
|
||||||
|
values: ["B cells", "Megakaryocytes"],
|
||||||
|
count: "357"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
continuous: [
|
||||||
|
{
|
||||||
|
metadata: "n_genes",
|
||||||
|
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
|
||||||
|
count: "1537"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
diffexp: {
|
||||||
|
cellset1: [
|
||||||
|
{ kind: "categorical", metadata: "louvain", values: ["B cells"] }
|
||||||
|
],
|
||||||
|
cellset2: [
|
||||||
|
{
|
||||||
|
kind: "categorical",
|
||||||
|
metadata: "louvain",
|
||||||
|
values: ["CD4 T cells", "NK cells"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gene-results": [
|
||||||
|
"HLA-DRB1",
|
||||||
|
"HLA-DPB1",
|
||||||
|
"CD79A",
|
||||||
|
"HLA-DPA1",
|
||||||
|
"HLA-DQA1",
|
||||||
|
"CD79B",
|
||||||
|
"HLA-DQB1",
|
||||||
|
"MS4A1",
|
||||||
|
"IL32",
|
||||||
|
"CD37"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
genes: {
|
||||||
|
bulkadd: ["S100A8", "FCGR3A", "LGALS2", "GSTP1"],
|
||||||
|
search: "ACD"
|
||||||
|
},
|
||||||
|
subset: {
|
||||||
|
cellset1: [
|
||||||
|
{
|
||||||
|
kind: "categorical",
|
||||||
|
metadata: "louvain",
|
||||||
|
values: ["B cells", "Megakaryocytes"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
count: "357",
|
||||||
|
categorical: {
|
||||||
|
louvain: {
|
||||||
|
"B cells": "342",
|
||||||
|
Megakaryocytes: "15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lasso: {
|
||||||
|
"coordinates-as-percent": { x1: 0.45, y1: 0.45, x2: 0.5, y2: 0.5 },
|
||||||
|
count: "67"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scatter: {
|
||||||
|
genes: { x: "S100A8", y: "FCGR3A" }
|
||||||
|
},
|
||||||
|
pan: {
|
||||||
|
"coordinates-as-percent": { x1: 0.75, y1: 0.75, x2: 0.35, y2: 0.35 }
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
panzoom: {
|
||||||
|
lasso: {
|
||||||
|
"coordinates-as-percent": { x1: 0.3, y1: 0.3, x2: 0.5, y2: 0.5 },
|
||||||
|
count: "24"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/*
|
||||||
|
Smoke test suite that will be run in Travis CI
|
||||||
|
|
||||||
|
Tests included in this file are expected to be relatively stable and test core features
|
||||||
|
*/
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
|
||||||
|
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
|
||||||
|
import { datasets } from "./data";
|
||||||
|
|
||||||
|
let browser, page, utils, cxgActions, spy;
|
||||||
|
const browserViewport = { width: 1280, height: 960 };
|
||||||
|
let data = datasets[DATASET];
|
||||||
|
|
||||||
|
if (DEBUG) jest.setTimeout(100000);
|
||||||
|
if (DEV) jest.setTimeout(10000);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const browserParams = DEV
|
||||||
|
? { headless: false, slowMo: 5 }
|
||||||
|
: DEBUG
|
||||||
|
? { headless: false, slowMo: 100, devtools: true }
|
||||||
|
: {};
|
||||||
|
browser = await puppeteer.launch(browserParams);
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.setViewport(browserViewport);
|
||||||
|
if (DEV || DEBUG) {
|
||||||
|
page.on("console", async msg => {
|
||||||
|
// If there is a console.error but an error is not thrown, this will ensure the test fails
|
||||||
|
if (msg.type() === "error") {
|
||||||
|
const errorMsgText = await Promise.all(
|
||||||
|
// TODO can we do this without internal properties?
|
||||||
|
msg.args().map(arg => arg._remoteObject.description)
|
||||||
|
);
|
||||||
|
throw new Error(`Console error: ${errorMsgText}`);
|
||||||
|
}
|
||||||
|
console.log(`PAGE LOG: ${msg.text()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
page.on("pageerror", err => {
|
||||||
|
throw new Error(`Console error: ${err}`);
|
||||||
|
});
|
||||||
|
utils = puppeteerUtils(page);
|
||||||
|
cxgActions = cellxgeneActions(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await page.goto(appUrlBase);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (!DEBUG) {
|
||||||
|
browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("did launch", async () => {
|
||||||
|
test("page launched", async () => {
|
||||||
|
let el = await utils.getOneElementInnerHTML("[data-testid='header']");
|
||||||
|
expect(el).toBe(data.title);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metadata loads", async () => {
|
||||||
|
test("categories and values from dataset appear", async () => {
|
||||||
|
for (const label in data.categorical) {
|
||||||
|
await utils.waitByID(`category-${label}`);
|
||||||
|
const categoryName = await utils.getOneElementInnerText(
|
||||||
|
`[data-testid="category-${label}"]`
|
||||||
|
);
|
||||||
|
expect(categoryName).toMatch(label);
|
||||||
|
await utils.clickOn(`category-expand-${label}`);
|
||||||
|
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||||
|
expect(Object.keys(categories)).toMatchObject(
|
||||||
|
Object.keys(data.categorical[label])
|
||||||
|
);
|
||||||
|
expect(Object.values(categories)).toMatchObject(
|
||||||
|
Object.values(data.categorical[label])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("continuous data appears", async () => {
|
||||||
|
for (const label in data.continuous) {
|
||||||
|
await utils.waitByID(`histogram-${label}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cell selection", async () => {
|
||||||
|
test("selects all cells cellset 1", async () => {
|
||||||
|
const cellCount = await cxgActions.cellSet(1);
|
||||||
|
expect(cellCount).toBe(data.dataframe.nObs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selects all cells cellset 2", async () => {
|
||||||
|
const cellCount = await cxgActions.cellSet(2);
|
||||||
|
expect(cellCount).toBe(data.dataframe.nObs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selects cells via lasso", async () => {
|
||||||
|
for (const cellset of data.cellsets.lasso) {
|
||||||
|
const cellset1 = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
cellset["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
await cxgActions.drag("layout-graph", cellset1.start, cellset1.end, true);
|
||||||
|
const cellCount = await cxgActions.cellSet(1);
|
||||||
|
expect(cellCount).toBe(cellset.count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selects cells via categorical", async () => {
|
||||||
|
for (const cellset of data.cellsets.categorical) {
|
||||||
|
await utils.clickOn(`category-expand-${cellset.metadata}`);
|
||||||
|
await utils.clickOn(`category-select-${cellset.metadata}`);
|
||||||
|
for (const val of cellset.values) {
|
||||||
|
await utils.clickOn(
|
||||||
|
`categorical-value-select-${cellset.metadata}-${val}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cellCount = await cxgActions.cellSet(1);
|
||||||
|
expect(cellCount).toBe(cellset.count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selects cells via continuous", async () => {
|
||||||
|
for (const cellset of data.cellsets.continuous) {
|
||||||
|
const histId = `histogram-${cellset.metadata}-plot-brush`;
|
||||||
|
const coords = await cxgActions.calcDragCoordinates(
|
||||||
|
histId,
|
||||||
|
cellset["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
await cxgActions.drag(histId, coords.start, coords.end);
|
||||||
|
const cellCount = await cxgActions.cellSet(1);
|
||||||
|
expect(cellCount).toBe(cellset.count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gene entry", async () => {
|
||||||
|
test("search for single gene", async () => {
|
||||||
|
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
|
||||||
|
await utils.typeInto("gene-search", data.genes.search);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForSelector(
|
||||||
|
`[data-testid='histogram-${data.genes.search}']`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bulk add genes", async () => {
|
||||||
|
await cxgActions.reset();
|
||||||
|
const testGenes = data.genes.bulkadd;
|
||||||
|
await utils.clickOn("section-bulk-add");
|
||||||
|
await utils.typeInto("input-bulk-add", testGenes.join(","));
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
const userGeneHist = await cxgActions.getAllHistograms(
|
||||||
|
"histogram-user-gene"
|
||||||
|
);
|
||||||
|
expect(userGeneHist).toEqual(expect.arrayContaining(testGenes));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("diffexp", async () => {
|
||||||
|
test("selects cells, saves them and performs diffexp", async () => {
|
||||||
|
for (const select of data.diffexp.cellset1) {
|
||||||
|
if (select.kind === "categorical") {
|
||||||
|
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await cxgActions.cellSet(1);
|
||||||
|
for (const select of data.diffexp.cellset2) {
|
||||||
|
if (select.kind === "categorical") {
|
||||||
|
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await cxgActions.cellSet(2);
|
||||||
|
await utils.clickOn("diffexp-button");
|
||||||
|
const diffExpHists = await cxgActions.getAllHistograms("histogram-diffexp");
|
||||||
|
expect(diffExpHists).toEqual(
|
||||||
|
expect.arrayContaining(data.diffexp["gene-results"])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
//
|
||||||
|
|
||||||
|
describe("subset/reset", async () => {
|
||||||
|
test("subset - cell count matches", async () => {
|
||||||
|
for (const select of data.subset.cellset1) {
|
||||||
|
if (select.kind === "categorical") {
|
||||||
|
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await utils.clickOn("subset-button");
|
||||||
|
for (const label in data.subset.categorical) {
|
||||||
|
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||||
|
expect(Object.keys(categories)).toMatchObject(
|
||||||
|
Object.keys(data.subset.categorical[label])
|
||||||
|
);
|
||||||
|
expect(Object.values(categories)).toMatchObject(
|
||||||
|
Object.values(data.subset.categorical[label])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reset after subset", async () => {
|
||||||
|
for (const select of data.subset.cellset1) {
|
||||||
|
if (select.kind === "categorical") {
|
||||||
|
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await utils.clickOn("subset-button");
|
||||||
|
for (const label in data.subset.categorical) {
|
||||||
|
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||||
|
expect(Object.keys(categories)).toMatchObject(
|
||||||
|
Object.keys(data.subset.categorical[label])
|
||||||
|
);
|
||||||
|
expect(Object.values(categories)).toMatchObject(
|
||||||
|
Object.values(data.subset.categorical[label])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await cxgActions.reset();
|
||||||
|
for (const label in data.categorical) {
|
||||||
|
await utils.waitByID(`category-${label}`);
|
||||||
|
const categoryName = await utils.getOneElementInnerText(
|
||||||
|
`[data-testid="category-${label}"]`
|
||||||
|
);
|
||||||
|
expect(categoryName).toMatch(label);
|
||||||
|
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||||
|
expect(Object.keys(categories)).toMatchObject(
|
||||||
|
Object.keys(data.categorical[label])
|
||||||
|
);
|
||||||
|
expect(Object.values(categories)).toMatchObject(
|
||||||
|
Object.values(data.categorical[label])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lasso after subset", async () => {
|
||||||
|
for (const select of data.subset.cellset1) {
|
||||||
|
if (select.kind === "categorical") {
|
||||||
|
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await utils.clickOn("subset-button");
|
||||||
|
const lassoSelection = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
data.subset.lasso["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
await cxgActions.drag(
|
||||||
|
"layout-graph",
|
||||||
|
lassoSelection.start,
|
||||||
|
lassoSelection.end,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const cellCount = await cxgActions.cellSet(1);
|
||||||
|
expect(cellCount).toBe(data.subset.lasso.count);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scatter plot", async () => {
|
||||||
|
test("scatter plot appears", async () => {
|
||||||
|
await cxgActions.reset();
|
||||||
|
const testGenes = data.scatter.genes;
|
||||||
|
await utils.clickOn("section-bulk-add");
|
||||||
|
await utils.typeInto("input-bulk-add", Object.values(testGenes).join(","));
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await utils.clickOn(`plot-x-${data.scatter.genes.x}`);
|
||||||
|
await utils.clickOn(`plot-y-${data.scatter.genes.y}`);
|
||||||
|
await utils.waitByID("scatterplot");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// interact with UI elements just that they do not break
|
||||||
|
describe("ui elements don't error", async () => {
|
||||||
|
test("color by", async () => {
|
||||||
|
for (const label in data.categorical) {
|
||||||
|
await utils.clickOn(`colorby-${label}`);
|
||||||
|
}
|
||||||
|
for (const label in data.continuous) {
|
||||||
|
await utils.clickOn(`colorby-${label}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("color by for gene", async () => {
|
||||||
|
await utils.typeInto("gene-search", data.genes.search);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForSelector(
|
||||||
|
`[data-testid='histogram-${data.genes.search}']`
|
||||||
|
);
|
||||||
|
await utils.clickOn(`colorby-${data.genes.search}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pan and zoom", async () => {
|
||||||
|
await utils.clickOn("mode-pan-zoom");
|
||||||
|
const panCoords = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
data.pan["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
await cxgActions.drag(
|
||||||
|
"layout-graph",
|
||||||
|
panCoords.start,
|
||||||
|
panCoords.end,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
await page.evaluate(`window.scrollBy(0, 1000);`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"preset": "jest-puppeteer",
|
||||||
|
"testMatch": [
|
||||||
|
"**/__tests__/**/?(*.)(spec|test).js?(x)"
|
||||||
|
],
|
||||||
|
"testURL": "http://localhost/",
|
||||||
|
"setupFiles": [
|
||||||
|
"../setupMissingGlobals.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
NOT run in Travis CI
|
||||||
|
|
||||||
|
UX tests using puppeteer to be run locally.
|
||||||
|
|
||||||
|
To run locally, ensure you are running the client is running on port 3000.
|
||||||
|
Then run jest --verbose false --config __tests__/e2e/e2eJestConfig.json feature.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
|
||||||
|
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
|
||||||
|
import { datasets } from "./data";
|
||||||
|
|
||||||
|
let browser, page, utils, cxgActions, spy;
|
||||||
|
const browserViewport = { width: 1280, height: 960 };
|
||||||
|
let data = datasets[DATASET].features;
|
||||||
|
|
||||||
|
if (DEBUG) jest.setTimeout(100000);
|
||||||
|
if (DEV) jest.setTimeout(10000);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const browserParams = DEV
|
||||||
|
? { headless: false, slowMo: 5 }
|
||||||
|
: DEBUG
|
||||||
|
? { headless: false, slowMo: 100, devtools: true }
|
||||||
|
: {};
|
||||||
|
browser = await puppeteer.launch(browserParams);
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.setViewport(browserViewport);
|
||||||
|
if (DEV || DEBUG) {
|
||||||
|
page.on("console", msg => console.log(`PAGE LOG: ${msg.text()}`));
|
||||||
|
}
|
||||||
|
page.on("pageerror", err => {
|
||||||
|
throw new Error(`Console error: ${err}`);
|
||||||
|
});
|
||||||
|
utils = puppeteerUtils(page);
|
||||||
|
cxgActions = cellxgeneActions(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await page.goto(appUrlBase);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (!DEBUG) {
|
||||||
|
browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("zoom interaction", async () => {
|
||||||
|
// Skip this test since UI is to hide lasso path when switching modes
|
||||||
|
test.skip("lasso visible after switching modes to pan/zoom", async () => {
|
||||||
|
const lassoSelection = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
data.panzoom.lasso["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
await cxgActions.drag(
|
||||||
|
"layout-graph",
|
||||||
|
lassoSelection.start,
|
||||||
|
lassoSelection.end,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
await utils.waitByID("lasso-element", { visible: true });
|
||||||
|
await utils.clickOn("mode-pan-zoom");
|
||||||
|
await utils.waitByID("lasso-element", { visible: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pan zoom mode resets lasso selection", async () => {
|
||||||
|
const lassoSelection = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
data.panzoom.lasso["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
await cxgActions.drag(
|
||||||
|
"layout-graph",
|
||||||
|
lassoSelection.start,
|
||||||
|
lassoSelection.end,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
await utils.waitByID("lasso-element", { visible: true });
|
||||||
|
const initialCount = await cxgActions.cellSet(1);
|
||||||
|
expect(initialCount).toBe(data.panzoom.lasso.count);
|
||||||
|
await utils.clickOn("mode-pan-zoom");
|
||||||
|
await utils.clickOn("mode-lasso");
|
||||||
|
const modeSwitchCount = await cxgActions.cellSet(1);
|
||||||
|
expect(modeSwitchCount).toBe(initialCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lasso moves after pan", async () => {
|
||||||
|
const lassoSelection = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
data.panzoom.lasso["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
await cxgActions.drag(
|
||||||
|
"layout-graph",
|
||||||
|
lassoSelection.start,
|
||||||
|
lassoSelection.end,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
await utils.waitByID("lasso-element", { visible: true });
|
||||||
|
const initialCount = await cxgActions.cellSet(1);
|
||||||
|
expect(initialCount).toBe(data.panzoom.lasso.count);
|
||||||
|
await utils.clickOn("mode-pan-zoom");
|
||||||
|
const panCoords = await cxgActions.calcDragCoordinates(
|
||||||
|
"layout-graph",
|
||||||
|
data.panzoom.lasso["coordinates-as-percent"]
|
||||||
|
);
|
||||||
|
await cxgActions.drag(
|
||||||
|
"layout-graph",
|
||||||
|
panCoords.start,
|
||||||
|
panCoords.end,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
await utils.clickOn("mode-lasso");
|
||||||
|
const panCount = await cxgActions.cellSet(2);
|
||||||
|
expect(panCount).toBe(initialCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
export const puppeteerUtils = puppeteerPage => ({
|
||||||
|
async waitByID(testid, props = {}) {
|
||||||
|
return await puppeteerPage.waitForSelector(
|
||||||
|
`[data-testid='${testid}']`,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async waitByClass(testclass, props = {}) {
|
||||||
|
return await puppeteerPage.waitForSelector(
|
||||||
|
`[data-testclass='${testclass}']`,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async typeInto(testid, text) {
|
||||||
|
// only works for text without special characters
|
||||||
|
await this.waitByID(testid);
|
||||||
|
// type ahead can be annoying if you don't pause before you type
|
||||||
|
await puppeteerPage.click(`[data-testid='${testid}']`);
|
||||||
|
await puppeteerPage.waitFor(200);
|
||||||
|
await puppeteerPage.type(`[data-testid='${testid}']`, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
async clickOn(testid) {
|
||||||
|
await this.waitByID(testid);
|
||||||
|
await puppeteerPage.click(`[data-testid='${testid}']`);
|
||||||
|
await puppeteerPage.waitFor(50);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getOneElementInnerHTML(selector) {
|
||||||
|
let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getOneElementInnerText(selector) {
|
||||||
|
let text = await puppeteerPage.$eval(selector, el => el.innerText);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cellxgeneActions = puppeteerPage => ({
|
||||||
|
async drag(testid, start, end, lasso = false) {
|
||||||
|
const layout = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
||||||
|
const elBox = await layout.boxModel();
|
||||||
|
const x1 = elBox.content[0].x + start.x;
|
||||||
|
const x2 = elBox.content[0].x + end.x;
|
||||||
|
const y1 = elBox.content[0].y + start.y;
|
||||||
|
const y2 = elBox.content[0].y + end.y;
|
||||||
|
await puppeteerPage.mouse.move(x1, y1);
|
||||||
|
await puppeteerPage.mouse.down();
|
||||||
|
if (lasso) {
|
||||||
|
await puppeteerPage.mouse.move(x2, y1);
|
||||||
|
await puppeteerPage.mouse.move(x2, y2);
|
||||||
|
await puppeteerPage.mouse.move(x1, y2);
|
||||||
|
await puppeteerPage.mouse.move(x1, y1);
|
||||||
|
} else {
|
||||||
|
await puppeteerPage.mouse.move(x2, y2);
|
||||||
|
}
|
||||||
|
await puppeteerPage.mouse.up();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllHistograms(testclass) {
|
||||||
|
await puppeteerUtils(puppeteerPage).waitByClass(testclass);
|
||||||
|
const histograms = await puppeteerPage.$$eval(
|
||||||
|
`[data-testclass=${testclass}]`,
|
||||||
|
els => {
|
||||||
|
return els.map(el => {
|
||||||
|
return el.dataset.testid.substring(
|
||||||
|
"histogram_".length,
|
||||||
|
el.dataset.testid.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return histograms;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllCategoriesAndCounts(category) {
|
||||||
|
await puppeteerUtils(puppeteerPage).waitByClass("categorical-row");
|
||||||
|
const categories = await puppeteerPage.$$eval(
|
||||||
|
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
|
||||||
|
els => {
|
||||||
|
let result = {};
|
||||||
|
els.forEach(el => {
|
||||||
|
const cat = el.querySelector("[data-testclass='categorical-value']")
|
||||||
|
.innerText;
|
||||||
|
const count = el.querySelector(
|
||||||
|
"[data-testclass='categorical-value-count']"
|
||||||
|
).innerText;
|
||||||
|
result[cat] = count;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return categories;
|
||||||
|
},
|
||||||
|
|
||||||
|
async cellSet(num) {
|
||||||
|
await puppeteerUtils(puppeteerPage).clickOn(`cellset-button-${num}`);
|
||||||
|
return await puppeteerUtils(puppeteerPage).getOneElementInnerText(
|
||||||
|
`[data-testid='cellset-count-${num}']`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetCategory(category) {
|
||||||
|
const checkboxId = `category-select-${category}`;
|
||||||
|
await puppeteerUtils(puppeteerPage).waitByID(checkboxId);
|
||||||
|
const checkedPseudoclass = await puppeteerPage.$eval(
|
||||||
|
`[data-testid='${checkboxId}']`,
|
||||||
|
el => {
|
||||||
|
return el.matches(":checked");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!checkedPseudoclass) {
|
||||||
|
await puppeteerUtils(puppeteerPage).clickOn(checkboxId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const categoryRow = await puppeteerUtils(puppeteerPage).waitByID(
|
||||||
|
`category-expand-${category}`
|
||||||
|
);
|
||||||
|
const isExpanded = await categoryRow.$(
|
||||||
|
"[data-testclass='category-expand-is-expanded']"
|
||||||
|
);
|
||||||
|
if (isExpanded) {
|
||||||
|
await puppeteerUtils(puppeteerPage).clickOn(
|
||||||
|
`category-expand-${category}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async calcDragCoordinates(testid, coordinateAsPercent) {
|
||||||
|
const el = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
||||||
|
const size = await el.boxModel();
|
||||||
|
const coords = {
|
||||||
|
start: {
|
||||||
|
x: Math.floor(size.width * coordinateAsPercent.x1),
|
||||||
|
y: Math.floor(size.height * coordinateAsPercent.y1)
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
x: Math.floor(size.width * coordinateAsPercent.x2),
|
||||||
|
y: Math.floor(size.height * coordinateAsPercent.y2)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return coords;
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectCategory(category, values, reset = true) {
|
||||||
|
if (reset) await this.resetCategory(category);
|
||||||
|
await puppeteerUtils(puppeteerPage).clickOn(`category-expand-${category}`);
|
||||||
|
await puppeteerUtils(puppeteerPage).clickOn(`category-select-${category}`);
|
||||||
|
for (const val of values) {
|
||||||
|
await puppeteerUtils(puppeteerPage).clickOn(
|
||||||
|
`categorical-value-select-${category}-${val}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
await puppeteerUtils(puppeteerPage).clickOn("reset");
|
||||||
|
// loading state never actually happens, reset is too fast
|
||||||
|
await page.waitFor(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import cascadeReducers from "../../src/reducers/cascade";
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
test("from Array", () => {
|
||||||
|
expect(cascadeReducers([["foo", () => 0]])).toBeInstanceOf(Function);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("from Map", () => {
|
||||||
|
expect(cascadeReducers(new Map([["foo", () => 0]]))).toBeInstanceOf(
|
||||||
|
Function
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cascade", () => {
|
||||||
|
test("expected arguments provided & cascade ordering", () => {
|
||||||
|
const topLevelState = {};
|
||||||
|
const topLevelAction = { type: "test" };
|
||||||
|
|
||||||
|
const reducer = cascadeReducers([
|
||||||
|
[
|
||||||
|
"foo",
|
||||||
|
(currentState, action, nextSharedState, prevSharedState) => {
|
||||||
|
expect(currentState).toBeUndefined();
|
||||||
|
expect(action).toEqual(topLevelAction);
|
||||||
|
expect(nextSharedState).toStrictEqual({});
|
||||||
|
expect(prevSharedState).toBe(topLevelState);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"bar",
|
||||||
|
(currentState, action, nextSharedState, prevSharedState) => {
|
||||||
|
expect(currentState).toBeUndefined();
|
||||||
|
expect(action).toEqual(topLevelAction);
|
||||||
|
expect(nextSharedState).toStrictEqual({ foo: 0 });
|
||||||
|
expect(prevSharedState).toBe(topLevelState);
|
||||||
|
return 99;
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nextState = reducer(topLevelState, topLevelAction);
|
||||||
|
expect(nextState).toStrictEqual({ foo: 0, bar: 99 });
|
||||||
|
expect(topLevelState).toStrictEqual({});
|
||||||
|
expect(topLevelAction).toStrictEqual({ type: "test" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import undoable from "../../src/reducers/undoable";
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
test("no keys", () => {
|
||||||
|
expect(() => undoable(() => {})).toThrow();
|
||||||
|
expect(() => undoable(() => {}, null)).toThrow();
|
||||||
|
expect(() => undoable(() => {}, [])).toThrow();
|
||||||
|
expect(() => undoable(() => {}, [], {})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple", () => {
|
||||||
|
expect(undoable(() => {}, ["foo"])).toBeInstanceOf(Function);
|
||||||
|
expect(undoable(() => {}, ["foo"], {})).toBeInstanceOf(Function);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles undefined initial state", () => {
|
||||||
|
expect(
|
||||||
|
undoable(() => {}, ["a"])(undefined, { type: "test" })
|
||||||
|
).toMatchObject({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undo", () => {
|
||||||
|
test("expected state modifications", () => {
|
||||||
|
const initialState = { a: 0, b: 1000 };
|
||||||
|
const reducer = state => {
|
||||||
|
return { a: state.a + 1, b: state.b + 1 };
|
||||||
|
};
|
||||||
|
const undoableReducer = undoable(reducer, ["a"]);
|
||||||
|
|
||||||
|
const s1 = undoableReducer(initialState, { type: "test" });
|
||||||
|
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||||
|
|
||||||
|
// test that only specified keys are undone
|
||||||
|
const s2 = undoableReducer(s1, { type: "@@undoable/undo" });
|
||||||
|
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||||
|
|
||||||
|
// test backstop when no more history
|
||||||
|
const s3 = undoableReducer(s2, { type: "@@undoable/undo" });
|
||||||
|
expect(s3).toMatchObject({ a: 0, b: 1001 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redo", () => {
|
||||||
|
const initialState = { a: 0, b: 1000 };
|
||||||
|
const reducer = state => {
|
||||||
|
return { a: state.a + 1, b: state.b + 1 };
|
||||||
|
};
|
||||||
|
let UR;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
UR = undoable(reducer, ["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expected state modifications", () => {
|
||||||
|
const s1 = UR(initialState, { type: "test" });
|
||||||
|
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||||
|
|
||||||
|
// verify undo->redo reverts state.
|
||||||
|
const s2 = UR(UR(s1, { type: "@@undoable/undo" }), {
|
||||||
|
type: "@@undoable/redo"
|
||||||
|
});
|
||||||
|
expect(s2).toMatchObject({ a: 1, b: 1001 });
|
||||||
|
|
||||||
|
// verify backstop when no redo future
|
||||||
|
const s3 = UR(s2, { type: "@@undoable/redo" });
|
||||||
|
expect(s3).toMatchObject({ a: 1, b: 1001 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("history cleared", () => {
|
||||||
|
// verify future cleared upon a normal state transition
|
||||||
|
const s1 = UR(initialState, { type: "test" });
|
||||||
|
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||||
|
const s2 = UR(s1, { type: "@@undoable/undo" });
|
||||||
|
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||||
|
const s3 = UR(s2, { type: "test" });
|
||||||
|
expect(s3).toMatchObject({ a: 1, b: 1002 });
|
||||||
|
const s4 = UR(s3, { type: "@@undoable/redo" });
|
||||||
|
expect(s4).toMatchObject({ a: 1, b: 1002 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO:
|
||||||
|
- historyLimit is enforced
|
||||||
|
- action filters
|
||||||
|
*/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
Define globals which are present in the client, but not in node (and therefore not in
|
||||||
|
the jest test environment).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TextDecoder, TextEncoder } from "util";
|
||||||
|
|
||||||
|
global.TextDecoder = TextDecoder;
|
||||||
|
global.TextEncoder = TextEncoder;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { rangeEncodeIndices } from "../../src/util/actionHelpers";
|
||||||
|
|
||||||
|
describe("rangeEncodeIndices", () => {
|
||||||
|
test("small array edge cases", () => {
|
||||||
|
expect(rangeEncodeIndices([])).toMatchObject([]);
|
||||||
|
expect(rangeEncodeIndices([1])).toMatchObject([1]);
|
||||||
|
expect(rangeEncodeIndices([1, 99])).toMatchObject([1, 99]);
|
||||||
|
expect(rangeEncodeIndices([99, 1])).toMatchObject([1, 99]);
|
||||||
|
expect(rangeEncodeIndices([1, 100, 4])).toMatchObject([1, 4, 100]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sorted flag", () => {
|
||||||
|
expect(rangeEncodeIndices([1, 9, 432], 10, true)).toMatchObject([
|
||||||
|
1,
|
||||||
|
9,
|
||||||
|
432
|
||||||
|
]);
|
||||||
|
expect(rangeEncodeIndices([1, 9, 432], 10, false)).toMatchObject([
|
||||||
|
1,
|
||||||
|
9,
|
||||||
|
432
|
||||||
|
]);
|
||||||
|
expect(rangeEncodeIndices([0, 1, 2, 3, 9, 10, 432], 2, true)).toMatchObject(
|
||||||
|
[[0, 3], [9, 10], 432]
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([0, 1, 2, 3, 9, 10, 432], 2, false)
|
||||||
|
).toMatchObject([[0, 3], [9, 10], 432]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("begin or end edge cases", () => {
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12, 99], 3, false)
|
||||||
|
).toMatchObject([[3, 7], [10, 12], 99]);
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12], 3, false)
|
||||||
|
).toMatchObject([[3, 7], [10, 12]]);
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12], 3, false)
|
||||||
|
).toMatchObject([0, [3, 7], [10, 12]]);
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12, 99], 3, false)
|
||||||
|
).toMatchObject([0, [3, 7], [10, 12], 99]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minRangeLength", () => {
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12, 99], 4, false)
|
||||||
|
).toMatchObject([[3, 7], 10, 11, 12, 99]);
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12], 4, false)
|
||||||
|
).toMatchObject([[3, 7], 10, 11, 12]);
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12], 4, false)
|
||||||
|
).toMatchObject([0, [3, 7], 10, 11, 12]);
|
||||||
|
expect(
|
||||||
|
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12, 99], 4, false)
|
||||||
|
).toMatchObject([0, [3, 7], 10, 11, 12, 99]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
import * as Dataframe from "../../../src/util/dataframe";
|
||||||
|
|
||||||
|
describe("dataframe constructor", () => {
|
||||||
|
test("empty dataframe", () => {
|
||||||
|
const df = new Dataframe.Dataframe([0, 0], []);
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.dims).toEqual([0, 0]);
|
||||||
|
expect(df).toHaveLength(0);
|
||||||
|
expect(df.icol(0)).not.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create with default indices", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[3, 2],
|
||||||
|
[new Int32Array(3).fill(0), new Int32Array(3).fill(1)]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.dims).toEqual([3, 2]);
|
||||||
|
expect(df.rowIndex).toBeInstanceOf(Dataframe.IdentityInt32Index);
|
||||||
|
expect(df.colIndex).toBeInstanceOf(Dataframe.IdentityInt32Index);
|
||||||
|
expect(df.at(0, 0)).toEqual(0);
|
||||||
|
expect(df.at(2, 1)).toEqual(1);
|
||||||
|
expect(df.iat(0, 0)).toEqual(0);
|
||||||
|
expect(df.iat(2, 1)).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create with labelled indices", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[3, 2],
|
||||||
|
[new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])],
|
||||||
|
new Dataframe.DenseInt32Index([2, 1, 0]),
|
||||||
|
new Dataframe.KeyIndex(["A", "B"])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.dims).toEqual([3, 2]);
|
||||||
|
|
||||||
|
expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index);
|
||||||
|
expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex);
|
||||||
|
expect(df.rowIndex.keys()).toEqual(new Int32Array([2, 1, 0]));
|
||||||
|
expect(df.colIndex.keys()).toEqual(["A", "B"]);
|
||||||
|
|
||||||
|
expect(df.at(0, "A")).toEqual(2);
|
||||||
|
expect(df.at(2, "B")).toEqual(3);
|
||||||
|
expect(df.iat(0, 0)).toEqual(0);
|
||||||
|
expect(df.iat(2, 1)).toEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("simple data access", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[4, 2],
|
||||||
|
[
|
||||||
|
new Float64Array([0.0, Number.NaN, Number.POSITIVE_INFINITY, 3.14159]),
|
||||||
|
["red", "blue", "green", "nan"]
|
||||||
|
],
|
||||||
|
new Dataframe.DenseInt32Index([3, 2, 1, 0]),
|
||||||
|
new Dataframe.KeyIndex(["numbers", "colors"])
|
||||||
|
);
|
||||||
|
|
||||||
|
test("iat", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
|
||||||
|
// present
|
||||||
|
expect(df.iat(0, 0)).toEqual(0.0);
|
||||||
|
expect(df.iat(0, 1)).toEqual("red");
|
||||||
|
expect(df.iat(1, 0)).toEqual(Number.NaN);
|
||||||
|
expect(df.iat(1, 1)).toEqual("blue");
|
||||||
|
expect(df.iat(2, 0)).toEqual(Number.POSITIVE_INFINITY);
|
||||||
|
expect(df.iat(2, 1)).toEqual("green");
|
||||||
|
expect(df.iat(3, 0)).toEqual(3.14159);
|
||||||
|
expect(df.iat(3, 1)).toEqual("nan");
|
||||||
|
|
||||||
|
// labels out of range have no defined behavior
|
||||||
|
});
|
||||||
|
|
||||||
|
test("at", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
|
||||||
|
// present
|
||||||
|
expect(df.at(3, "numbers")).toEqual(0.0);
|
||||||
|
expect(df.at(3, "colors")).toEqual("red");
|
||||||
|
expect(df.at(2, "numbers")).toEqual(Number.NaN);
|
||||||
|
expect(df.at(2, "colors")).toEqual("blue");
|
||||||
|
expect(df.at(1, "numbers")).toEqual(Number.POSITIVE_INFINITY);
|
||||||
|
expect(df.at(1, "colors")).toEqual("green");
|
||||||
|
expect(df.at(0, "numbers")).toEqual(3.14159);
|
||||||
|
expect(df.at(0, "colors")).toEqual("nan");
|
||||||
|
|
||||||
|
// labels out of range have no defined behavior
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ihas", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
|
||||||
|
// present
|
||||||
|
expect(df.ihas(0, 0)).toBeTruthy();
|
||||||
|
expect(df.ihas(1, 1)).toBeTruthy();
|
||||||
|
expect(df.ihas(3, 1)).toBeTruthy();
|
||||||
|
|
||||||
|
// not present
|
||||||
|
expect(df.ihas(-1, -1)).toBeFalsy();
|
||||||
|
expect(df.ihas(0, 99)).toBeFalsy();
|
||||||
|
expect(df.ihas(99, 0)).toBeFalsy();
|
||||||
|
expect(df.ihas(99, 99)).toBeFalsy();
|
||||||
|
expect(df.ihas(-1, 0)).toBeFalsy();
|
||||||
|
expect(df.ihas(0, -1)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
|
||||||
|
// present
|
||||||
|
expect(df.has(3, "numbers")).toBeTruthy();
|
||||||
|
expect(df.has(0, "numbers")).toBeTruthy();
|
||||||
|
expect(df.has(3, "colors")).toBeTruthy();
|
||||||
|
expect(df.has(0, "colors")).toBeTruthy();
|
||||||
|
|
||||||
|
// not present
|
||||||
|
expect(df.has(3, "foo")).toBeFalsy();
|
||||||
|
expect(df.has(-1, "numbers")).toBeFalsy();
|
||||||
|
expect(df.has(-1, -1)).toBeFalsy();
|
||||||
|
expect(df.has(null, null)).toBeFalsy();
|
||||||
|
expect(df.has(0, "foo")).toBeFalsy();
|
||||||
|
expect(df.has(99, "numbers")).toBeFalsy();
|
||||||
|
expect(df.has(99, "foo")).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dataframe subsetting", () => {
|
||||||
|
describe("subset", () => {
|
||||||
|
const sourceDf = new Dataframe.Dataframe(
|
||||||
|
[3, 4],
|
||||||
|
[
|
||||||
|
new Int32Array([0, 1, 2]),
|
||||||
|
["A", "B", "C"],
|
||||||
|
new Float32Array([4.4, 5.5, 6.6]),
|
||||||
|
["red", "green", "blue"]
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["int32", "string", "float32", "colors"])
|
||||||
|
);
|
||||||
|
|
||||||
|
test("all rows, one column", () => {
|
||||||
|
const dfA = sourceDf.subset(null, ["colors"]);
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([3, 1]);
|
||||||
|
expect(dfA.iat(0, 0)).toEqual("red");
|
||||||
|
expect(dfA.at(2, "colors")).toEqual("blue");
|
||||||
|
expect(dfA.col("colors").asArray()).toEqual(["red", "green", "blue"]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(["red", "green", "blue"]);
|
||||||
|
expect(dfA.col("colors").asArray()).toEqual(
|
||||||
|
sourceDf.col("colors").asArray()
|
||||||
|
);
|
||||||
|
expect(dfA.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(["colors"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all rows, two columns", () => {
|
||||||
|
const dfB = sourceDf.subset(null, ["colors", "float32"]);
|
||||||
|
expect(dfB).toBeDefined();
|
||||||
|
expect(dfB.dims).toEqual([3, 2]);
|
||||||
|
expect(dfB.iat(0, 0)).toBeCloseTo(4.4);
|
||||||
|
expect(dfB.iat(0, 1)).toEqual("red");
|
||||||
|
expect(dfB.at(2, "colors")).toEqual("blue");
|
||||||
|
expect(dfB.at(2, "float32")).toBeCloseTo(6.6);
|
||||||
|
expect(dfB.col("colors").asArray()).toEqual(["red", "green", "blue"]);
|
||||||
|
expect(dfB.col("float32").asArray()).toEqual(
|
||||||
|
new Float32Array([4.4, 5.5, 6.6])
|
||||||
|
);
|
||||||
|
expect(dfB.icol(0).asArray()).toEqual(dfB.col("float32").asArray());
|
||||||
|
expect(dfB.icol(1).asArray()).toEqual(dfB.col("colors").asArray());
|
||||||
|
expect(dfB.col("colors").asArray()).toEqual(
|
||||||
|
sourceDf.col("colors").asArray()
|
||||||
|
);
|
||||||
|
expect(dfB.col("float32").asArray()).toEqual(
|
||||||
|
sourceDf.col("float32").asArray()
|
||||||
|
);
|
||||||
|
expect(dfB.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
|
||||||
|
expect(dfB.colIndex.keys()).toEqual(["float32", "colors"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one row, all columns", () => {
|
||||||
|
const dfC = sourceDf.subset([1], null);
|
||||||
|
expect(dfC).toBeDefined();
|
||||||
|
expect(dfC.dims).toEqual([1, 4]);
|
||||||
|
expect(dfC.iat(0, 0)).toEqual(1);
|
||||||
|
expect(dfC.iat(0, 1)).toEqual("B");
|
||||||
|
expect(dfC.iat(0, 2)).toBeCloseTo(5.5);
|
||||||
|
expect(dfC.iat(0, 3)).toEqual("green");
|
||||||
|
expect(dfC.rowIndex.keys()).toEqual(new Int32Array([1]));
|
||||||
|
expect(dfC.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two rows, all columns", () => {
|
||||||
|
const dfD = sourceDf.subset([0, 2], null);
|
||||||
|
expect(dfD).toBeDefined();
|
||||||
|
expect(dfD.dims).toEqual([2, 4]);
|
||||||
|
expect(dfD.icol(0).asArray()).toEqual(new Int32Array([0, 2]));
|
||||||
|
expect(dfD.icol(1).asArray()).toEqual(["A", "C"]);
|
||||||
|
expect(dfD.icol(2).asArray()).toEqual(new Float32Array([4.4, 6.6]));
|
||||||
|
expect(dfD.icol(3).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfD.rowIndex.keys()).toEqual(new Int32Array([0, 2]));
|
||||||
|
expect(dfD.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all rows, all columns", () => {
|
||||||
|
const dfE = sourceDf.subset(null, null);
|
||||||
|
expect(dfE).toBeDefined();
|
||||||
|
expect(dfE.dims).toEqual([3, 4]);
|
||||||
|
expect(dfE.icol(0).asArray()).toEqual(sourceDf.icol(0).asArray());
|
||||||
|
expect(dfE.icol(1).asArray()).toEqual(sourceDf.icol(1).asArray());
|
||||||
|
expect(dfE.icol(2).asArray()).toEqual(sourceDf.icol(2).asArray());
|
||||||
|
expect(dfE.icol(3).asArray()).toEqual(sourceDf.icol(3).asArray());
|
||||||
|
expect(dfE.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys());
|
||||||
|
expect(dfE.colIndex.keys()).toEqual(sourceDf.colIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two rows, two colums", () => {
|
||||||
|
const dfF = sourceDf.subset([0, 2], ["int32", "float32"]);
|
||||||
|
expect(dfF).toBeDefined();
|
||||||
|
expect(dfF.dims).toEqual([2, 2]);
|
||||||
|
expect(dfF.icol(0).asArray()).toEqual(new Int32Array([0, 2]));
|
||||||
|
expect(dfF.icol(1).asArray()).toEqual(new Float32Array([4.4, 6.6]));
|
||||||
|
expect(dfF.rowIndex.keys()).toEqual(new Int32Array([0, 2]));
|
||||||
|
expect(dfF.colIndex.keys()).toEqual(["int32", "float32"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withRowIndex", () => {
|
||||||
|
const df = sourceDf.subset(
|
||||||
|
null,
|
||||||
|
["int32", "float32"],
|
||||||
|
new Dataframe.DenseInt32Index([3, 2, 1])
|
||||||
|
);
|
||||||
|
expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex);
|
||||||
|
expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index);
|
||||||
|
expect(df.at(3, "int32")).toEqual(df.iat(0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withRowIndex error checks", () => {
|
||||||
|
expect(() =>
|
||||||
|
sourceDf.subset(null, ["red"], new Dataframe.IdentityInt32Index(1))
|
||||||
|
).toThrow(RangeError);
|
||||||
|
expect(() =>
|
||||||
|
sourceDf.subset(null, ["red"], new Dataframe.DenseInt32Index([0, 1]))
|
||||||
|
).toThrow(RangeError);
|
||||||
|
expect(() =>
|
||||||
|
sourceDf.subset(null, ["red"], new Dataframe.KeyIndex([0, 1, 2, 3]))
|
||||||
|
).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isubsetMask", () => {
|
||||||
|
const sourceDf = new Dataframe.Dataframe(
|
||||||
|
[3, 4],
|
||||||
|
[
|
||||||
|
new Int32Array([0, 1, 2]),
|
||||||
|
["A", "B", "C"],
|
||||||
|
new Float32Array([4.4, 5.5, 6.6]),
|
||||||
|
["red", "green", "blue"]
|
||||||
|
],
|
||||||
|
new Dataframe.DenseInt32Index([2, 4, 6]),
|
||||||
|
new Dataframe.KeyIndex(["int32", "string", "float32", "colors"])
|
||||||
|
);
|
||||||
|
|
||||||
|
const dfA = sourceDf.isubsetMask(
|
||||||
|
new Uint8Array([0, 1, 1]),
|
||||||
|
new Uint8Array([1, 0, 0, 1])
|
||||||
|
);
|
||||||
|
expect(dfA.dims).toEqual([2, 2]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(new Int32Array([1, 2]));
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual(["green", "blue"]);
|
||||||
|
expect(dfA.rowIndex.keys()).toEqual(new Int32Array([4, 6]));
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(["int32", "colors"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dataframe factories", () => {
|
||||||
|
test("create", () => {
|
||||||
|
const df = Dataframe.Dataframe.create(
|
||||||
|
[3, 3],
|
||||||
|
[
|
||||||
|
new Array(3).fill(0),
|
||||||
|
new Int16Array(3).fill(99),
|
||||||
|
new Float64Array(3).fill(1.1)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.dims).toEqual([3, 3]);
|
||||||
|
expect(df).toHaveLength(3);
|
||||||
|
expect(df.iat(0, 0)).toEqual(0);
|
||||||
|
expect(df.iat(1, 1)).toEqual(99);
|
||||||
|
expect(df.iat(2, 2)).toBeCloseTo(1.1);
|
||||||
|
expect(df.iat(0, 0)).toEqual(df.at(0, 0));
|
||||||
|
expect(df.iat(1, 1)).toEqual(df.at(1, 1));
|
||||||
|
expect(df.iat(2, 2)).toEqual(df.at(2, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clone", () => {
|
||||||
|
const dfA = new Dataframe.Dataframe(
|
||||||
|
[3, 2],
|
||||||
|
[new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])],
|
||||||
|
new Dataframe.DenseInt32Index([2, 1, 0]),
|
||||||
|
new Dataframe.KeyIndex(["A", "B"])
|
||||||
|
);
|
||||||
|
|
||||||
|
const dfB = dfA.clone();
|
||||||
|
expect(dfB).not.toBe(dfA);
|
||||||
|
expect(dfB.dims).toEqual(dfA.dims);
|
||||||
|
expect(dfB).toHaveLength(dfA.length);
|
||||||
|
expect(dfB.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
expect(dfB.colIndex.keys()).toEqual(dfA.colIndex.keys());
|
||||||
|
for (let i = 0, l = dfB.dims[1]; i < l; i += 1) {
|
||||||
|
expect(dfB.icol(i).asArray()).toEqual(dfA.icol(i).asArray());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withCol", () => {
|
||||||
|
test("KeyIndex", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 2],
|
||||||
|
[["red", "blue"], [true, false]],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["colors", "bools"])
|
||||||
|
);
|
||||||
|
const dfA = df.withCol("numbers", [1, 0]);
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 3]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.col("numbers").asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(["colors", "bools", "numbers"]);
|
||||||
|
expect(df.colIndex.keys()).toEqual(["colors", "bools"]);
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DenseInt32Index", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 2],
|
||||||
|
[["red", "blue"], [true, false]],
|
||||||
|
null,
|
||||||
|
new Dataframe.DenseInt32Index([74, 75])
|
||||||
|
);
|
||||||
|
const dfA = df.withCol(72, [1, 0]);
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 3]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.col(74).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.col(75).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.col(72).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(new Int32Array([74, 75, 72]));
|
||||||
|
expect(df.colIndex.keys()).toEqual(new Int32Array([74, 75]));
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DenseInt32Index promote", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 2],
|
||||||
|
[["red", "blue"], [true, false]],
|
||||||
|
null,
|
||||||
|
new Dataframe.DenseInt32Index([74, 75])
|
||||||
|
);
|
||||||
|
const dfA = df.withCol(999, [1, 0]);
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 3]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.col(74).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.col(75).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.col(999).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(new Int32Array([74, 75, 999]));
|
||||||
|
expect(df.colIndex.keys()).toEqual(new Int32Array([74, 75]));
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("IdentityInt32Index with last", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 2],
|
||||||
|
[["red", "blue"], [true, false]],
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const dfA = df.withCol(2, [1, 0]);
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 3]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.col(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.col(1).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.col(2).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
|
||||||
|
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1]));
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("IdentityInt32Index promote", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 2],
|
||||||
|
[["red", "blue"], [true, false]],
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const dfA = df.withCol(99, [1, 0]);
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 3]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.icol(2).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.col(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.col(1).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.col(99).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1, 99]));
|
||||||
|
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1]));
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handle column dimensions correctly", () => {
|
||||||
|
/*
|
||||||
|
there are two conditions:
|
||||||
|
- empty dataframe - will accept an add of any dimensionality
|
||||||
|
- non-empty dataframe - added column must match row-count dimension
|
||||||
|
*/
|
||||||
|
test("empty.withCol", () => {
|
||||||
|
const edf = Dataframe.Dataframe.empty();
|
||||||
|
const df = edf.withCol("foo", [1, 2, 3]);
|
||||||
|
|
||||||
|
expect(edf).toBeDefined();
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(edf).not.toEqual(df);
|
||||||
|
expect(df.dims).toEqual([3, 1]);
|
||||||
|
expect(df.icol(0).asArray()).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withCol dimension check", () => {
|
||||||
|
const dfA = new Dataframe.Dataframe([1, 1], [["a"]]);
|
||||||
|
expect(() => {
|
||||||
|
dfA.withCol(1, []);
|
||||||
|
}).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dropCol", () => {
|
||||||
|
test("KeyIndex", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 3],
|
||||||
|
[["red", "blue"], [true, false], [1, 0]],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
||||||
|
);
|
||||||
|
const dfA = df.dropCol("colors");
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 2]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.col("numbers").asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(["bools", "numbers"]);
|
||||||
|
expect(df.colIndex.keys()).toEqual(["colors", "bools", "numbers"]);
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("IdentityInt32Index drop first", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 3],
|
||||||
|
[["red", "blue"], [true, false], [1, 0]],
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const dfA = df.dropCol(0);
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 2]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual([true, false]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
|
||||||
|
expect(df.col(1).asArray()).toEqual(dfA.col(1).asArray());
|
||||||
|
expect(df.col(2).asArray()).toEqual(dfA.col(2).asArray());
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(new Int32Array([1, 2]));
|
||||||
|
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("IdentityInt32Index drop last", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 3],
|
||||||
|
[["red", "blue"], [true, false], [1, 0]],
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const dfA = df.dropCol(2);
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 2]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([true, false]);
|
||||||
|
expect(df.col(0).asArray()).toEqual(dfA.col(0).asArray());
|
||||||
|
expect(df.col(1).asArray()).toEqual(dfA.col(1).asArray());
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(new Int32Array([0, 1]));
|
||||||
|
expect(df.colIndex.keys()).toEqual(new Int32Array([0, 1, 2]));
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DenseInt32Index", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[2, 3],
|
||||||
|
[["red", "blue"], [true, false], [1, 0]],
|
||||||
|
null,
|
||||||
|
new Dataframe.DenseInt32Index([102, 101, 100])
|
||||||
|
);
|
||||||
|
const dfA = df.dropCol(101);
|
||||||
|
|
||||||
|
expect(dfA).toBeDefined();
|
||||||
|
expect(dfA.dims).toEqual([2, 2]);
|
||||||
|
expect(dfA.icol(0).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.icol(1).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.col(100).asArray()).toEqual([1, 0]);
|
||||||
|
expect(dfA.col(102).asArray()).toEqual(["red", "blue"]);
|
||||||
|
expect(dfA.colIndex.keys()).toEqual(new Int32Array([102, 100]));
|
||||||
|
expect(df.colIndex.keys()).toEqual(new Int32Array([102, 101, 100]));
|
||||||
|
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapColumns", () => {
|
||||||
|
test("identity", () => {
|
||||||
|
const dfA = Dataframe.Dataframe.create(
|
||||||
|
[3, 3],
|
||||||
|
[
|
||||||
|
new Array(3).fill(0),
|
||||||
|
new Int16Array(3).fill(99),
|
||||||
|
new Float64Array(3).fill(1.1)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const dfB = dfA.mapColumns((col, idx) => {
|
||||||
|
expect(dfA.icol(idx).asArray()).toBe(col);
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
expect(dfA).not.toBe(dfB);
|
||||||
|
expect(dfA.dims).toEqual(dfB.dims);
|
||||||
|
for (let c = 0; c < dfA.dims[1]; c += 1) {
|
||||||
|
expect(dfA.icol(c).asArray()).toBe(dfB.icol(c).asArray());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transform", () => {
|
||||||
|
const dfA = Dataframe.Dataframe.create(
|
||||||
|
[3, 3],
|
||||||
|
[new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)]
|
||||||
|
);
|
||||||
|
const dfB = dfA.mapColumns(() => {
|
||||||
|
return new Array(3).fill(1);
|
||||||
|
});
|
||||||
|
expect(dfA).not.toBe(dfB);
|
||||||
|
expect(dfB.iat(0, 0)).toEqual(1);
|
||||||
|
expect(dfB.iat(0, 1)).toEqual(1);
|
||||||
|
expect(dfB.iat(0, 2)).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dataframe col", () => {
|
||||||
|
let df = null;
|
||||||
|
beforeEach(() => {
|
||||||
|
df = new Dataframe.Dataframe(
|
||||||
|
[2, 2],
|
||||||
|
[[true, false], [1, 0]],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["A", "B"])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("col", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.col("A")).toBe(df.icol(0));
|
||||||
|
expect(df.col("B")).toBe(df.icol(1));
|
||||||
|
expect(df.col("undefined")).toBeUndefined();
|
||||||
|
expect(df.icol("undefined")).toBeUndefined();
|
||||||
|
|
||||||
|
const colA = df.col("A");
|
||||||
|
expect(colA).toBeInstanceOf(Function);
|
||||||
|
expect(colA.asArray).toBeInstanceOf(Function);
|
||||||
|
expect(colA.has).toBeInstanceOf(Function);
|
||||||
|
expect(colA.ihas).toBeInstanceOf(Function);
|
||||||
|
expect(colA.indexOf).toBeInstanceOf(Function);
|
||||||
|
expect(colA.iget).toBeInstanceOf(Function);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("col.asArray", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.col("A").asArray()).toEqual([true, false]);
|
||||||
|
expect(df.icol(0).asArray()).toEqual([true, false]);
|
||||||
|
expect(df.col("B").asArray()).toEqual([1, 0]);
|
||||||
|
expect(df.icol(1).asArray()).toEqual([1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("col.has", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.col("A").has(-1)).toBe(false);
|
||||||
|
expect(df.col("A").has(0)).toBe(true);
|
||||||
|
expect(df.col("A").has(1)).toBe(true);
|
||||||
|
expect(df.col("A").has(2)).toBe(false);
|
||||||
|
expect(df.col("B").has(-1)).toBe(false);
|
||||||
|
expect(df.col("B").has(0)).toBe(true);
|
||||||
|
expect(df.col("B").has(1)).toBe(true);
|
||||||
|
expect(df.col("B").has(2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("col.ihas", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.col("A").ihas(-1)).toBe(false);
|
||||||
|
expect(df.col("A").ihas(0)).toBe(true);
|
||||||
|
expect(df.col("A").ihas(1)).toBe(true);
|
||||||
|
expect(df.col("A").ihas(2)).toBe(false);
|
||||||
|
expect(df.col("B").ihas(-1)).toBe(false);
|
||||||
|
expect(df.col("B").ihas(0)).toBe(true);
|
||||||
|
expect(df.col("B").ihas(1)).toBe(true);
|
||||||
|
expect(df.col("B").ihas(2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("col.iget", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.col("A").iget(0)).toEqual(df.iat(0, 0));
|
||||||
|
expect(df.col("B").iget(1)).toEqual(df.iat(1, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("col.indexOf", () => {
|
||||||
|
expect(df).toBeDefined();
|
||||||
|
expect(df.col("A").indexOf(true)).toEqual(0);
|
||||||
|
expect(df.col("A").indexOf(false)).toEqual(1);
|
||||||
|
expect(df.col("A").indexOf(99)).toBeUndefined();
|
||||||
|
expect(df.col("A").indexOf(undefined)).toBeUndefined();
|
||||||
|
expect(df.col("A").indexOf(1)).toBeUndefined();
|
||||||
|
|
||||||
|
expect(df.col("B").indexOf(1)).toEqual(0);
|
||||||
|
expect(df.col("B").indexOf(0)).toEqual(1);
|
||||||
|
expect(df.col("B").indexOf(99)).toBeUndefined();
|
||||||
|
expect(df.col("B").indexOf(undefined)).toBeUndefined();
|
||||||
|
expect(df.col("B").indexOf(true)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import * as Dataframe from "../../../src/util/dataframe";
|
||||||
|
|
||||||
|
function float32Conversion(f) {
|
||||||
|
return new Float32Array([f])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dataframe column summary", () => {
|
||||||
|
test("empty column test", () => {
|
||||||
|
const df = Dataframe.Dataframe.create([0, 1], [[]]);
|
||||||
|
const summary = df.icol(0).summarize();
|
||||||
|
expect(summary).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: [],
|
||||||
|
categoryCounts: new Map(),
|
||||||
|
numCategories: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple test", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[1, 6],
|
||||||
|
[
|
||||||
|
["n1"],
|
||||||
|
["hi"],
|
||||||
|
[true],
|
||||||
|
new Float32Array([39.3]),
|
||||||
|
new Int32Array([99]),
|
||||||
|
[1]
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex([
|
||||||
|
"name",
|
||||||
|
"nameString",
|
||||||
|
"nameBoolean",
|
||||||
|
"nameFloat32",
|
||||||
|
"nameInt32",
|
||||||
|
"nameCategorical"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(df.icol(0).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: ["n1"],
|
||||||
|
categoryCounts: new Map([["n1", 1]]),
|
||||||
|
numCategories: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(1).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: ["hi"],
|
||||||
|
categoryCounts: new Map([["hi", 1]]),
|
||||||
|
numCategories: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(2).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: [true],
|
||||||
|
categoryCounts: new Map([[true, 1]]),
|
||||||
|
numCategories: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(3).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: false,
|
||||||
|
min: float32Conversion(39.3),
|
||||||
|
max: float32Conversion(39.3),
|
||||||
|
nan: 0,
|
||||||
|
ninf: 0,
|
||||||
|
pinf: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(4).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: false,
|
||||||
|
min: 99,
|
||||||
|
max: 99,
|
||||||
|
nan: 0,
|
||||||
|
ninf: 0,
|
||||||
|
pinf: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(5).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: [1],
|
||||||
|
categoryCounts: new Map([[1, 1]]),
|
||||||
|
numCategories: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multi test", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[3, 6],
|
||||||
|
[
|
||||||
|
["n0", "n1", "n2"],
|
||||||
|
["hi", "hi", "bye"],
|
||||||
|
[false, true, true],
|
||||||
|
new Float32Array([39.3, 39.3, 0]),
|
||||||
|
new Int32Array([99, 99, 99]),
|
||||||
|
[1, false, "0"]
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex([
|
||||||
|
"name",
|
||||||
|
"nameString",
|
||||||
|
"nameBoolean",
|
||||||
|
"nameFloat32",
|
||||||
|
"nameInt32",
|
||||||
|
"nameCategorical"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(df.icol(0).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: expect.arrayContaining(["n0", "n1", "n2"]),
|
||||||
|
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 1]]),
|
||||||
|
numCategories: 3
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(1).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: expect.arrayContaining(["hi", "bye"]),
|
||||||
|
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
|
||||||
|
numCategories: 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(2).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: expect.arrayContaining([true, false]),
|
||||||
|
categoryCounts: new Map([[true, 2], [false, 1]]),
|
||||||
|
numCategories: 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(3).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: false,
|
||||||
|
min: 0,
|
||||||
|
max: float32Conversion(39.3),
|
||||||
|
nan: 0,
|
||||||
|
ninf: 0,
|
||||||
|
pinf: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(4).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: false,
|
||||||
|
min: 99,
|
||||||
|
max: 99,
|
||||||
|
nan: 0,
|
||||||
|
ninf: 0,
|
||||||
|
pinf: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(5).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: expect.arrayContaining([1, false, "0"]),
|
||||||
|
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
|
||||||
|
numCategories: 3
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-finite numbers", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[4, 6],
|
||||||
|
[
|
||||||
|
["n0", "n1", "n2", "n2"],
|
||||||
|
["hi", "hi", "bye", "bye"],
|
||||||
|
[false, true, true, true],
|
||||||
|
new Float32Array([
|
||||||
|
39.3,
|
||||||
|
Number.NEGATIVE_INFINITY,
|
||||||
|
Number.NaN,
|
||||||
|
Number.POSITIVE_INFINITY
|
||||||
|
]),
|
||||||
|
new Int32Array([99, 99, 99, 99]),
|
||||||
|
[1, false, "0", "0"]
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex([
|
||||||
|
"name",
|
||||||
|
"nameString",
|
||||||
|
"nameBoolean",
|
||||||
|
"nameFloat32",
|
||||||
|
"nameInt32",
|
||||||
|
"nameCategorical"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(df.icol(0).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: expect.arrayContaining(["n0", "n1", "n2"]),
|
||||||
|
categoryCounts: new Map([["n0", 1], ["n1", 1], ["n2", 2]]),
|
||||||
|
numCategories: 3
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(1).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: expect.arrayContaining(["hi", "bye"]),
|
||||||
|
categoryCounts: new Map([["hi", 2], ["bye", 1]]),
|
||||||
|
numCategories: 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(2).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: expect.arrayContaining([true, false]),
|
||||||
|
categoryCounts: new Map([[true, 2], [false, 1]]),
|
||||||
|
numCategories: 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(3).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: false,
|
||||||
|
min: float32Conversion(39.3),
|
||||||
|
max: float32Conversion(39.3),
|
||||||
|
nan: 1,
|
||||||
|
ninf: 1,
|
||||||
|
pinf: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(4).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: false,
|
||||||
|
min: 99,
|
||||||
|
max: 99,
|
||||||
|
nan: 0,
|
||||||
|
ninf: 0,
|
||||||
|
pinf: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(df.icol(5).summarize()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
categorical: true,
|
||||||
|
categories: expect.arrayContaining([1, false, "0"]),
|
||||||
|
categoryCounts: new Map([[1, 1], [false, 1], ["0", 1]]),
|
||||||
|
numCategories: 3
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
Test cases for nameCreators.js.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
layoutDimensionName,
|
||||||
|
obsAnnoDimensionName,
|
||||||
|
diffexpDimensionName,
|
||||||
|
userDefinedDimensionName,
|
||||||
|
makeContinuousDimensionName
|
||||||
|
} from "../../src/util/nameCreators";
|
||||||
|
|
||||||
|
describe("nameCreators", () => {
|
||||||
|
const nameCreators = [
|
||||||
|
layoutDimensionName,
|
||||||
|
obsAnnoDimensionName,
|
||||||
|
diffexpDimensionName,
|
||||||
|
userDefinedDimensionName
|
||||||
|
];
|
||||||
|
|
||||||
|
test("check for namespace isolation", () => {
|
||||||
|
const foo = "foo";
|
||||||
|
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(foo));
|
||||||
|
|
||||||
|
const bar = "bar";
|
||||||
|
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(fn(bar)));
|
||||||
|
|
||||||
|
nameCreators.forEach(fn => {
|
||||||
|
const allOtherFn = nameCreators.filter(elmnt => elmnt !== fn);
|
||||||
|
allOtherFn.forEach(otherFn => expect(fn(foo)).not.toBe(otherFn(foo)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("check for legal keys", () => {
|
||||||
|
/* need namespace creators to return strings only */
|
||||||
|
nameCreators.forEach(fn => expect(fn("X")).toMatch(/X/));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("makeContinuousDimensionName", () => {
|
||||||
|
test("namespaces", () => {
|
||||||
|
expect(makeContinuousDimensionName({ isObs: true }, "OK")).toMatch(/OK/);
|
||||||
|
expect(makeContinuousDimensionName({ isDiffExp: true }, "OK")).toMatch(
|
||||||
|
/OK/
|
||||||
|
);
|
||||||
|
expect(makeContinuousDimensionName({ isUserDefined: true }, "OK")).toMatch(
|
||||||
|
/OK/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error handling", () => {
|
||||||
|
expect(() => makeContinuousDimensionName({}, "oops")).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
makeContinuousDimensionName(
|
||||||
|
{ isObs: false, isDiffExp: false, isUserDefined: false },
|
||||||
|
"oops"
|
||||||
|
)
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
makeContinuousDimensionName({ isObs: true }, "OK")
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
makeContinuousDimensionName({ isDiffExp: true }, "OK")
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
makeContinuousDimensionName({ isUserDefined: true }, "OK")
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import quantile from "../../src/util/quantile";
|
||||||
|
|
||||||
|
describe("quantile", () => {
|
||||||
|
test("single q", () => {
|
||||||
|
const arr = new Float32Array([9, 3, 5, 6, 0]);
|
||||||
|
expect(quantile([1.0], arr)).toMatchObject([9]);
|
||||||
|
expect(quantile([0.9], arr)).toMatchObject([9]);
|
||||||
|
expect(quantile([0.8], arr)).toMatchObject([9]);
|
||||||
|
expect(quantile([0.7], arr)).toMatchObject([6]);
|
||||||
|
expect(quantile([0.6], arr)).toMatchObject([6]);
|
||||||
|
expect(quantile([0.5], arr)).toMatchObject([5]);
|
||||||
|
expect(quantile([0.4], arr)).toMatchObject([5]);
|
||||||
|
expect(quantile([0.3], arr)).toMatchObject([3]);
|
||||||
|
expect(quantile([0.2], arr)).toMatchObject([3]);
|
||||||
|
expect(quantile([0.1], arr)).toMatchObject([0]);
|
||||||
|
expect(quantile([0], arr)).toMatchObject([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multi q", () => {
|
||||||
|
const arr = new Float32Array([9, 3, 5, 6, 0]);
|
||||||
|
expect(quantile([0, 0.25, 0.5, 0.75, 1.0], arr)).toMatchObject([
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
9
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/* eslint no-bitwise: "off" */
|
||||||
|
import _ from "lodash";
|
||||||
|
import { flatbuffers } from "flatbuffers";
|
||||||
|
import { NetEncoding } from "../../../src/util/stateManager/matrix_generated";
|
||||||
|
|
||||||
|
/*
|
||||||
|
test data mocking REST 0.2 API responses. Used in several tests.
|
||||||
|
*/
|
||||||
|
const nObs = 10;
|
||||||
|
const nVar = 32;
|
||||||
|
const field4Categories = [83, true, "foo", 2.222222];
|
||||||
|
const fieldDCategories = [99, false, "mumble", 3.1415];
|
||||||
|
|
||||||
|
const aConfigResponse = {
|
||||||
|
config: {
|
||||||
|
features: [
|
||||||
|
{ method: "POST", path: "/cluster/", available: false },
|
||||||
|
{ method: "POST", path: "/layout/", available: false },
|
||||||
|
{ method: "POST", path: "/diffexp/", available: false },
|
||||||
|
{ method: "POST", path: "/saveLocal/", available: false }
|
||||||
|
],
|
||||||
|
displayNames: {
|
||||||
|
engine: "the little engine that could",
|
||||||
|
dataset: "all your zeros are mine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const aSchemaResponse = {
|
||||||
|
schema: {
|
||||||
|
dataframe: {
|
||||||
|
nObs,
|
||||||
|
nVar,
|
||||||
|
type: "float32"
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
obs: [
|
||||||
|
{ name: "name", type: "string" },
|
||||||
|
{ name: "field1", type: "int32" },
|
||||||
|
{ name: "field2", type: "float32" },
|
||||||
|
{ name: "field3", type: "boolean" },
|
||||||
|
{
|
||||||
|
name: "field4",
|
||||||
|
type: "categorical",
|
||||||
|
categories: field4Categories
|
||||||
|
}
|
||||||
|
],
|
||||||
|
var: [
|
||||||
|
{ name: "name", type: "string" },
|
||||||
|
{ name: "fieldA", type: "int32" },
|
||||||
|
{ name: "fieldB", type: "float32" },
|
||||||
|
{ name: "fieldC", type: "boolean" },
|
||||||
|
{
|
||||||
|
name: "fieldD",
|
||||||
|
type: "categorical",
|
||||||
|
categories: fieldDCategories
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const anAnnotationsObsJSONResponse = {
|
||||||
|
names: ["name", "field1", "field2", "field3", "field4"],
|
||||||
|
data: _()
|
||||||
|
.range(nObs)
|
||||||
|
.map(idx => [
|
||||||
|
idx,
|
||||||
|
`obs${idx}`,
|
||||||
|
2 * idx,
|
||||||
|
idx + 0.0133,
|
||||||
|
!!(idx & 1),
|
||||||
|
field4Categories[idx % field4Categories.length]
|
||||||
|
])
|
||||||
|
.value()
|
||||||
|
};
|
||||||
|
|
||||||
|
const anAnnotationsVarJSONResponse = {
|
||||||
|
names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"],
|
||||||
|
data: _()
|
||||||
|
.range(nVar)
|
||||||
|
.map(idx => [
|
||||||
|
idx,
|
||||||
|
10 * idx,
|
||||||
|
idx + 2.90143,
|
||||||
|
!!(idx & 1),
|
||||||
|
fieldDCategories[idx % fieldDCategories.length],
|
||||||
|
`var${idx}`
|
||||||
|
])
|
||||||
|
.value()
|
||||||
|
};
|
||||||
|
|
||||||
|
function encodeTypedArray(builder, uType, uData) {
|
||||||
|
const uTypeName = NetEncoding.TypedArray[uType];
|
||||||
|
const ArrayType = NetEncoding[uTypeName];
|
||||||
|
const dv = ArrayType.createDataVector(builder, uData);
|
||||||
|
builder.startObject(1);
|
||||||
|
builder.addFieldOffset(0, dv, 0);
|
||||||
|
return builder.endObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMatrix(columns, colIndex = undefined) {
|
||||||
|
const utf8Encoder = new TextEncoder("utf-8");
|
||||||
|
const builder = new flatbuffers.Builder(1024);
|
||||||
|
const cols = _.map(columns, carr => {
|
||||||
|
let uType;
|
||||||
|
let tarr;
|
||||||
|
if (_.every(carr, _.isNumber)) {
|
||||||
|
uType = NetEncoding.TypedArray.Float32Array;
|
||||||
|
tarr = encodeTypedArray(builder, uType, new Float32Array(carr));
|
||||||
|
} else {
|
||||||
|
uType = NetEncoding.TypedArray.JSONEncodedArray;
|
||||||
|
const json = JSON.stringify(carr);
|
||||||
|
const jsonUTF8 = utf8Encoder.encode(json);
|
||||||
|
tarr = encodeTypedArray(builder, uType, jsonUTF8);
|
||||||
|
}
|
||||||
|
NetEncoding.Column.startColumn(builder);
|
||||||
|
NetEncoding.Column.addUType(builder, uType);
|
||||||
|
NetEncoding.Column.addU(builder, tarr);
|
||||||
|
return NetEncoding.Column.endColumn(builder);
|
||||||
|
});
|
||||||
|
|
||||||
|
const encColumns = NetEncoding.Matrix.createColumnsVector(builder, cols);
|
||||||
|
|
||||||
|
let encColIndex;
|
||||||
|
if (colIndex) {
|
||||||
|
encColIndex = encodeTypedArray(
|
||||||
|
builder,
|
||||||
|
NetEncoding.TypedArray.JSONEncodedArray,
|
||||||
|
utf8Encoder.encode(JSON.stringify(colIndex))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NetEncoding.Matrix.startMatrix(builder);
|
||||||
|
NetEncoding.Matrix.addNRows(builder, columns[0].length);
|
||||||
|
NetEncoding.Matrix.addNCols(builder, columns.length);
|
||||||
|
NetEncoding.Matrix.addColumns(builder, encColumns);
|
||||||
|
if (colIndex) {
|
||||||
|
NetEncoding.Matrix.addColIndexType(
|
||||||
|
builder,
|
||||||
|
NetEncoding.TypedArray.JSONEncodedArray
|
||||||
|
);
|
||||||
|
NetEncoding.Matrix.addColIndex(builder, encColIndex);
|
||||||
|
}
|
||||||
|
const root = NetEncoding.Matrix.endMatrix(builder);
|
||||||
|
builder.finish(root);
|
||||||
|
return builder.asUint8Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
const anAnnotationsObsFBSResponse = (() => {
|
||||||
|
const columns = _.zip(...anAnnotationsObsJSONResponse.data).slice(1);
|
||||||
|
return encodeMatrix(columns, anAnnotationsObsJSONResponse.names);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const anAnnotationsVarFBSResponse = (() => {
|
||||||
|
const columns = _.zip(...anAnnotationsVarJSONResponse.data).slice(1);
|
||||||
|
return encodeMatrix(columns, anAnnotationsVarJSONResponse.names);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const aLayoutFBSResponse = (() => {
|
||||||
|
const coords = [
|
||||||
|
new Float32Array(nObs).fill(Math.random()),
|
||||||
|
new Float32Array(nObs).fill(Math.random())
|
||||||
|
];
|
||||||
|
const builder = new flatbuffers.Builder(1024);
|
||||||
|
|
||||||
|
const cols = _.map(coords, carr => {
|
||||||
|
const cdv = NetEncoding.Float32Array.createDataVector(builder, carr);
|
||||||
|
NetEncoding.Float32Array.startFloat32Array(builder);
|
||||||
|
NetEncoding.Float32Array.addData(builder, cdv);
|
||||||
|
const floatArr = NetEncoding.Float32Array.endFloat32Array(builder);
|
||||||
|
|
||||||
|
NetEncoding.Column.startColumn(builder);
|
||||||
|
NetEncoding.Column.addUType(builder, NetEncoding.TypedArray.Float32Array);
|
||||||
|
NetEncoding.Column.addU(builder, floatArr);
|
||||||
|
return NetEncoding.Column.endColumn(builder);
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = NetEncoding.Matrix.createColumnsVector(builder, cols);
|
||||||
|
|
||||||
|
NetEncoding.Matrix.startMatrix(builder);
|
||||||
|
NetEncoding.Matrix.addNRows(builder, nObs);
|
||||||
|
NetEncoding.Matrix.addNCols(builder, coords.length);
|
||||||
|
NetEncoding.Matrix.addColumns(builder, columns);
|
||||||
|
const matrix = NetEncoding.Matrix.endMatrix(builder);
|
||||||
|
builder.finish(matrix);
|
||||||
|
return builder.asUint8Array();
|
||||||
|
})();
|
||||||
|
|
||||||
|
const aDataObsResponse = {
|
||||||
|
var: [2, 4, 29],
|
||||||
|
obs: _()
|
||||||
|
.range(nObs)
|
||||||
|
.map(idx => [idx, Math.random(), Math.random(), Math.random()])
|
||||||
|
.value()
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
aLayoutFBSResponse as layoutObs,
|
||||||
|
aDataObsResponse as dataObs,
|
||||||
|
anAnnotationsVarFBSResponse as annotationsVar,
|
||||||
|
anAnnotationsObsFBSResponse as annotationsObs,
|
||||||
|
aSchemaResponse as schema,
|
||||||
|
aConfigResponse as config
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as Universe from "../../../src/util/stateManager/universe";
|
||||||
|
import * as Dataframe from "../../../src/util/dataframe";
|
||||||
|
import * as REST from "./sampleResponses";
|
||||||
|
|
||||||
|
describe("createUniverseFromResponse", () => {
|
||||||
|
/*
|
||||||
|
test createUniverseFromResponse - this function converts
|
||||||
|
a set of REST 0.2 responses into a "new" Universe.
|
||||||
|
|
||||||
|
createUniverseFromResponse(
|
||||||
|
configResponse,
|
||||||
|
schemaResponse,
|
||||||
|
annotationsObsResponse,
|
||||||
|
annotationsVarResponse,
|
||||||
|
layoutObsResponse
|
||||||
|
) --> Universe
|
||||||
|
|
||||||
|
where:
|
||||||
|
configResponse: GET /.../config
|
||||||
|
schemaResponse: GET /.../schema
|
||||||
|
annotationsObsResponse: GET /.../annotations/obs
|
||||||
|
annotationsVarResponse: GET /.../annotations/var
|
||||||
|
layoutObsResponse: GET /.../layout/obs
|
||||||
|
|
||||||
|
See spec in docs/REST_API.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("create from test data", () => {
|
||||||
|
/*
|
||||||
|
create a universe from sample data nad validate its shape & contents
|
||||||
|
*/
|
||||||
|
const { nObs, nVar } = REST.schema.schema.dataframe;
|
||||||
|
const universe = Universe.createUniverseFromResponse(
|
||||||
|
REST.config,
|
||||||
|
REST.schema,
|
||||||
|
REST.annotationsObs,
|
||||||
|
REST.annotationsVar,
|
||||||
|
REST.layoutObs
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(universe).toBeDefined();
|
||||||
|
expect(universe).toMatchObject(
|
||||||
|
expect.objectContaining({
|
||||||
|
nObs,
|
||||||
|
nVar,
|
||||||
|
schema: REST.schema.schema,
|
||||||
|
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
obsLayout: expect.any(Dataframe.Dataframe),
|
||||||
|
varData: expect.any(Dataframe.Dataframe)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(universe.obsAnnotations.dims).toEqual([
|
||||||
|
nObs,
|
||||||
|
REST.schema.schema.annotations.obs.length
|
||||||
|
]);
|
||||||
|
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
|
||||||
|
expect(universe.obsLayout.colIndex.keys()).toEqual(["X", "Y"]);
|
||||||
|
expect(universe.varAnnotations.dims).toEqual([
|
||||||
|
nVar,
|
||||||
|
REST.schema.schema.annotations.var.length
|
||||||
|
]);
|
||||||
|
expect(universe.varData.isEmpty()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import * as Universe from "../../../src/util/stateManager/universe";
|
||||||
|
import * as World from "../../../src/util/stateManager/world";
|
||||||
|
import * as Dataframe from "../../../src/util/dataframe";
|
||||||
|
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||||
|
import { DimTypes } from "../../../src/util/typedCrossfilter/crossfilter";
|
||||||
|
import * as REST from "./sampleResponses";
|
||||||
|
import {
|
||||||
|
obsAnnoDimensionName,
|
||||||
|
layoutDimensionName
|
||||||
|
} from "../../../src/util/nameCreators";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Helper - creates universe, world, corssfilter and dimensionMap from
|
||||||
|
the default REST test response.
|
||||||
|
*/
|
||||||
|
const defaultBigBang = () => {
|
||||||
|
/* create unverse, world, crossfilter and dimensionMap */
|
||||||
|
/* create universe */
|
||||||
|
const universe = Universe.createUniverseFromResponse(
|
||||||
|
REST.config,
|
||||||
|
REST.schema,
|
||||||
|
REST.annotationsObs,
|
||||||
|
REST.annotationsVar,
|
||||||
|
REST.layoutObs
|
||||||
|
);
|
||||||
|
/* create world */
|
||||||
|
const world = World.createWorldFromEntireUniverse(universe);
|
||||||
|
/* create crossfilter */
|
||||||
|
const crossfilter = World.createObsDimensions(
|
||||||
|
new Crossfilter(world.obsAnnotations),
|
||||||
|
world
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
universe,
|
||||||
|
world,
|
||||||
|
crossfilter
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createWorldFromEntireUniverse", () => {
|
||||||
|
test("create from REST sample", () => {
|
||||||
|
const universe = Universe.createUniverseFromResponse(
|
||||||
|
REST.config,
|
||||||
|
REST.schema,
|
||||||
|
REST.annotationsObs,
|
||||||
|
REST.annotationsVar,
|
||||||
|
REST.layoutObs
|
||||||
|
);
|
||||||
|
expect(universe).toBeDefined();
|
||||||
|
|
||||||
|
const world = World.createWorldFromEntireUniverse(universe);
|
||||||
|
expect(world).toBeDefined();
|
||||||
|
|
||||||
|
expect(world).toMatchObject(
|
||||||
|
expect.objectContaining({
|
||||||
|
nObs: universe.nObs,
|
||||||
|
nVar: universe.nVar,
|
||||||
|
schema: universe.schema,
|
||||||
|
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
obsLayout: expect.any(Dataframe.Dataframe),
|
||||||
|
varData: expect.any(Dataframe.Dataframe),
|
||||||
|
clipQuantiles: { min: 0, max: 1 },
|
||||||
|
unclipped: {
|
||||||
|
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
varData: expect.any(Dataframe.Dataframe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createWorldFromCurrentSelection", () => {
|
||||||
|
test("create from REST sample", () => {
|
||||||
|
const {
|
||||||
|
universe,
|
||||||
|
world: originalWorld,
|
||||||
|
crossfilter: originalCrossfilter
|
||||||
|
} = defaultBigBang();
|
||||||
|
|
||||||
|
/* mock a selection */
|
||||||
|
const crossfilter = originalCrossfilter
|
||||||
|
.select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 })
|
||||||
|
.select(obsAnnoDimensionName("field3"), {
|
||||||
|
mode: "exact",
|
||||||
|
values: [false]
|
||||||
|
});
|
||||||
|
|
||||||
|
/* create the world from the selection */
|
||||||
|
const world = World.createWorldBySelection(
|
||||||
|
universe,
|
||||||
|
originalWorld,
|
||||||
|
crossfilter
|
||||||
|
);
|
||||||
|
expect(world).toBeDefined();
|
||||||
|
expect(world.nObs).toEqual(crossfilter.countSelected());
|
||||||
|
|
||||||
|
/*
|
||||||
|
calculate expected values and match against result
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* matchFilter must match the dimension filters above */
|
||||||
|
const matchFilter = (df, row) => {
|
||||||
|
const field1 = df.at(row, "field1");
|
||||||
|
const field3 = df.at(row, "field3");
|
||||||
|
return field1 >= 0 && field1 < 5 && !field3;
|
||||||
|
};
|
||||||
|
const matchingIndices = _()
|
||||||
|
.range(universe.nObs)
|
||||||
|
.filter(idx => matchFilter(universe.obsAnnotations, idx))
|
||||||
|
.value();
|
||||||
|
|
||||||
|
expect(world).toMatchObject(
|
||||||
|
expect.objectContaining({
|
||||||
|
nObs: matchingIndices.length,
|
||||||
|
nVar: universe.nVar,
|
||||||
|
schema: universe.schema,
|
||||||
|
clipQuantiles: { min: 0, max: 1 },
|
||||||
|
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
obsLayout: expect.any(Dataframe.Dataframe),
|
||||||
|
varData: expect.any(Dataframe.Dataframe),
|
||||||
|
unclipped: {
|
||||||
|
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
varData: expect.any(Dataframe.Dataframe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(world.obsAnnotations.rowIndex.keys()).toEqual(
|
||||||
|
new Int32Array(matchingIndices)
|
||||||
|
);
|
||||||
|
expect(world.obsAnnotations.colIndex.keys()).toEqual(
|
||||||
|
universe.obsAnnotations.colIndex.keys()
|
||||||
|
);
|
||||||
|
expect(world.obsLayout.rowIndex.keys()).toEqual(
|
||||||
|
new Int32Array(matchingIndices)
|
||||||
|
);
|
||||||
|
expect(world.obsLayout.colIndex.keys()).toEqual(["X", "Y"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createObsDimensionMap", () => {
|
||||||
|
test("when universe eq world", () => {
|
||||||
|
/*
|
||||||
|
check for:
|
||||||
|
- creates a dimension for all obsAnnotations, PLUS X/Y layout
|
||||||
|
- check that dimension typing is sane
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { crossfilter } = defaultBigBang();
|
||||||
|
const annotationNames = _.map(
|
||||||
|
REST.schema.schema.annotations.obs,
|
||||||
|
c => c.name
|
||||||
|
);
|
||||||
|
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name");
|
||||||
|
expect(crossfilter).toBeDefined();
|
||||||
|
annotationNames.forEach(name => {
|
||||||
|
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
|
||||||
|
if (name === "name") {
|
||||||
|
expect(dim).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
const { type } = schemaByObsName[name];
|
||||||
|
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||||
|
expect(dim.dim).toBeInstanceOf(DimTypes.enum);
|
||||||
|
} else {
|
||||||
|
expect(dim.dim).toBeInstanceOf(DimTypes.scalar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
crossfilter.dimensions[layoutDimensionName("XY")].dim
|
||||||
|
).toBeInstanceOf(DimTypes.spatial);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("worldEqUniverse", () => {
|
||||||
|
const { universe, world } = defaultBigBang();
|
||||||
|
const result = World.worldEqUniverse(world, universe);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
countCategoryValues2D,
|
||||||
|
clearCaches
|
||||||
|
} from "../../../src/util/stateManager/worldUtil";
|
||||||
|
import * as Dataframe from "../../../src/util/dataframe";
|
||||||
|
|
||||||
|
describe("WorldUtil cache management", () => {
|
||||||
|
test("empty", () => {
|
||||||
|
const count = countCategoryValues2D(
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
new Dataframe.Dataframe([0, 0], [])
|
||||||
|
);
|
||||||
|
expect(count).toMatchObject(new Map());
|
||||||
|
expect(count.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple couts", () => {
|
||||||
|
const df = new Dataframe.Dataframe(
|
||||||
|
[3, 2],
|
||||||
|
[[0, 0, 1], [false, true, false]],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["a", "b"])
|
||||||
|
);
|
||||||
|
const count = countCategoryValues2D("a", "b", df);
|
||||||
|
expect(count).toMatchObject(
|
||||||
|
new Map([
|
||||||
|
[0, new Map([[true, 1], [false, 1]])],
|
||||||
|
[1, new Map([[false, 1]])]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memo cache clear", () => {
|
||||||
|
clearCaches();
|
||||||
|
const df1 = new Dataframe.Dataframe([0, 0], []);
|
||||||
|
const df2 = new Dataframe.Dataframe(
|
||||||
|
[3, 2],
|
||||||
|
[[0, 0, 1], [false, true, false]],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["a", "b"])
|
||||||
|
);
|
||||||
|
|
||||||
|
const count1 = countCategoryValues2D("a", "b", df1);
|
||||||
|
const count2 = countCategoryValues2D("a", "b", df1);
|
||||||
|
const count3 = countCategoryValues2D("a", "b", df1.clone());
|
||||||
|
const count4 = countCategoryValues2D("a", "b", df2);
|
||||||
|
|
||||||
|
clearCaches();
|
||||||
|
const count10 = countCategoryValues2D("a", "b", df1);
|
||||||
|
const count11 = countCategoryValues2D("a", "b", df2);
|
||||||
|
|
||||||
|
expect(count1).toEqual(count2);
|
||||||
|
expect(count1).toEqual(count3);
|
||||||
|
expect(count1).toEqual(count10);
|
||||||
|
expect(count1).not.toBe(count3);
|
||||||
|
expect(count1).not.toBe(count10);
|
||||||
|
|
||||||
|
expect(count4).toEqual(count11);
|
||||||
|
expect(count4).not.toBe(count11);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
|
||||||
|
import BitArray from "../../../src/util/typedCrossfilter/bitArray";
|
||||||
|
|
||||||
|
const defaultTestLength = 8;
|
||||||
|
|
||||||
|
describe("default select state", () => {
|
||||||
|
test("newly created Bitarray should be deselected", () => {
|
||||||
|
const ba = new BitArray(defaultTestLength);
|
||||||
|
expect(ba).toBeDefined();
|
||||||
|
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dim = ba.allocDimension();
|
||||||
|
expect(dim).toBeDefined();
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.freeDimension(dim);
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("select and deselect", () => {
|
||||||
|
test("selectAll and deselectAll", () => {
|
||||||
|
const ba = new BitArray(defaultTestLength);
|
||||||
|
expect(ba).toBeDefined();
|
||||||
|
const dim1 = ba.allocDimension();
|
||||||
|
expect(dim1).toBeDefined();
|
||||||
|
ba.selectAll(dim1);
|
||||||
|
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dim2 = ba.allocDimension();
|
||||||
|
expect(dim2).toBeDefined();
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.selectAll(dim2);
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.deselectAll(dim1);
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.deselectAll(dim2);
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.selectAll(dim1);
|
||||||
|
ba.selectAll(dim2);
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.freeDimension(dim1);
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.freeDimension(dim2);
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selectOne and deselectOne", () => {
|
||||||
|
const ba = new BitArray(defaultTestLength);
|
||||||
|
expect(ba).toBeDefined();
|
||||||
|
const dim = ba.allocDimension();
|
||||||
|
expect(dim).toBeDefined();
|
||||||
|
|
||||||
|
ba.selectOne(dim, 0);
|
||||||
|
expect(ba.isSelected(0)).toEqual(true);
|
||||||
|
for (let i = 1; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.deselectOne(dim, 0);
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.selectOne(dim, 1);
|
||||||
|
expect(ba.isSelected(1)).toEqual(true);
|
||||||
|
expect(ba.isSelected(0)).toEqual(false);
|
||||||
|
for (let i = 2; i < defaultTestLength; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.selectAll(dim);
|
||||||
|
ba.deselectOne(dim, defaultTestLength - 1);
|
||||||
|
expect(ba.isSelected(defaultTestLength - 1)).toEqual(false);
|
||||||
|
for (let i = 0; i < defaultTestLength - 1; i += 1) {
|
||||||
|
expect(ba.isSelected(i)).toEqual(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectionCount", () => {
|
||||||
|
test("simple", () => {
|
||||||
|
const ba = new BitArray(defaultTestLength);
|
||||||
|
expect(ba).toBeDefined();
|
||||||
|
const dim1 = ba.allocDimension();
|
||||||
|
expect(dim1).toBeDefined();
|
||||||
|
const dim2 = ba.allocDimension();
|
||||||
|
expect(dim2).toBeDefined();
|
||||||
|
|
||||||
|
expect(ba.selectionCount()).toEqual(0);
|
||||||
|
ba.selectAll(dim1);
|
||||||
|
expect(ba.selectionCount()).toEqual(0);
|
||||||
|
ba.selectAll(dim2);
|
||||||
|
expect(ba.selectionCount()).toEqual(defaultTestLength);
|
||||||
|
|
||||||
|
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||||
|
ba.deselectOne(dim1, i);
|
||||||
|
expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1);
|
||||||
|
expect(ba.selectionCount()).toEqual(ba.countAllOnes());
|
||||||
|
}
|
||||||
|
|
||||||
|
ba.freeDimension(dim1);
|
||||||
|
ba.freeDimension(dim2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fillBySelection", () => {
|
||||||
|
test("sets values correctly", () => {
|
||||||
|
const ba = new BitArray(defaultTestLength);
|
||||||
|
expect(ba).toBeDefined();
|
||||||
|
const dim = ba.allocDimension();
|
||||||
|
expect(dim).toBeDefined();
|
||||||
|
|
||||||
|
const arr = new Int32Array(defaultTestLength);
|
||||||
|
arr.fill(0);
|
||||||
|
const truth = new Int32Array(defaultTestLength);
|
||||||
|
truth.fill(0);
|
||||||
|
|
||||||
|
// initial state should be deselected
|
||||||
|
ba.fillBySelection(arr, 1, 0);
|
||||||
|
expect(arr).toEqual(expect.not.arrayContaining([1]));
|
||||||
|
|
||||||
|
// selectAll
|
||||||
|
ba.selectAll(dim);
|
||||||
|
ba.fillBySelection(arr, 1, 0);
|
||||||
|
expect(arr).toEqual(expect.not.arrayContaining([0]));
|
||||||
|
|
||||||
|
// deselectOne
|
||||||
|
ba.deselectOne(dim, 3);
|
||||||
|
ba.fillBySelection(arr, 1, 0);
|
||||||
|
truth.fill(1);
|
||||||
|
truth[3] = 0;
|
||||||
|
expect(arr).toEqual(truth);
|
||||||
|
|
||||||
|
// deselectAll
|
||||||
|
ba.deselectAll(dim);
|
||||||
|
ba.fillBySelection(arr, 1, 0);
|
||||||
|
truth.fill(0);
|
||||||
|
expect(arr).toEqual(truth);
|
||||||
|
|
||||||
|
// selectOne
|
||||||
|
ba.selectOne(dim, 5);
|
||||||
|
ba.fillBySelection(arr, 6, 1);
|
||||||
|
truth.fill(1);
|
||||||
|
truth[5] = 6;
|
||||||
|
expect(arr).toEqual(truth);
|
||||||
|
|
||||||
|
// should be deselected after dimension disposal
|
||||||
|
ba.freeDimension(dim);
|
||||||
|
ba.fillBySelection(arr, 3, 9);
|
||||||
|
truth.fill(9);
|
||||||
|
expect(arr).toEqual(truth);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import { polygonContains } from "d3";
|
||||||
|
|
||||||
|
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||||
|
|
||||||
|
const someData = [
|
||||||
|
{
|
||||||
|
date: "2011-11-14T16:17:54Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 190,
|
||||||
|
tip: 100,
|
||||||
|
type: "tab",
|
||||||
|
productIDs: ["001"],
|
||||||
|
coords: [0, 0],
|
||||||
|
nonFinite: 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T16:20:19Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 190,
|
||||||
|
tip: 100,
|
||||||
|
type: "tab",
|
||||||
|
productIDs: ["001", "005"],
|
||||||
|
coords: [0.4, 0.4],
|
||||||
|
nonFinite: Number.NaN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T16:28:54Z",
|
||||||
|
quantity: 1,
|
||||||
|
total: 300,
|
||||||
|
tip: 200,
|
||||||
|
type: "visa",
|
||||||
|
productIDs: ["004", "005"],
|
||||||
|
coords: [0.3, 0.1],
|
||||||
|
nonFinite: Number.POSITIVE_INFINITY
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T16:30:43Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 90,
|
||||||
|
tip: 0,
|
||||||
|
type: "tab",
|
||||||
|
productIDs: ["001", "002"],
|
||||||
|
coords: [0.392, 0.1],
|
||||||
|
nonFinite: Number.NEGATIVE_INFINITY
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T16:48:46Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 90,
|
||||||
|
tip: 0,
|
||||||
|
type: "tab",
|
||||||
|
productIDs: ["005"],
|
||||||
|
coords: [0.7, 0.0482],
|
||||||
|
nonFinite: 1.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T16:53:41Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 90,
|
||||||
|
tip: 0,
|
||||||
|
type: "tab",
|
||||||
|
productIDs: ["001", "004", "005"],
|
||||||
|
coords: [0.9999, 1.0],
|
||||||
|
nonFinite: Number.NaN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T16:54:06Z",
|
||||||
|
quantity: 1,
|
||||||
|
total: 100,
|
||||||
|
tip: 0,
|
||||||
|
type: "cash",
|
||||||
|
productIDs: ["001", "002", "003", "004", "005"],
|
||||||
|
coords: [0.384, 0.6938],
|
||||||
|
nonFinite: 99.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T16:58:03Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 90,
|
||||||
|
tip: 0,
|
||||||
|
type: "tab",
|
||||||
|
productIDs: ["001"],
|
||||||
|
coords: [0.4822, 0.482],
|
||||||
|
nonFinite: Number.NaN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T17:07:21Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 90,
|
||||||
|
tip: 0,
|
||||||
|
type: "tab",
|
||||||
|
productIDs: ["004", "005"],
|
||||||
|
coords: [0.2234, 0],
|
||||||
|
nonFinite: Number.NaN
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T17:22:59Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 90,
|
||||||
|
tip: 0,
|
||||||
|
type: "tab",
|
||||||
|
productIDs: ["001", "002", "004", "005"],
|
||||||
|
coords: [0.382, 0.38485],
|
||||||
|
nonFinite: -1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T17:25:45Z",
|
||||||
|
quantity: 2,
|
||||||
|
total: 200,
|
||||||
|
tip: 0,
|
||||||
|
type: "cash",
|
||||||
|
productIDs: ["002"],
|
||||||
|
coords: [0.998, 0.8472],
|
||||||
|
nonFinite: 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2011-11-14T17:29:52Z",
|
||||||
|
quantity: 1,
|
||||||
|
total: 200,
|
||||||
|
tip: 100,
|
||||||
|
type: "visa",
|
||||||
|
productIDs: ["004"],
|
||||||
|
coords: [0.8273, 0.3384],
|
||||||
|
nonFinite: 0.0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let payments = null;
|
||||||
|
beforeEach(() => {
|
||||||
|
payments = new Crossfilter(someData);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ImmutableTypedCrossfilter", () => {
|
||||||
|
test("create crossfilter", () => {
|
||||||
|
expect(payments).toBeDefined();
|
||||||
|
expect(payments.size()).toEqual(someData.length);
|
||||||
|
expect(payments.all()).toEqual(someData);
|
||||||
|
|
||||||
|
const p = payments
|
||||||
|
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||||
|
.select("quantity", { mode: "all" });
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
expect(p.all()).toEqual(someData);
|
||||||
|
expect(p.size()).toEqual(someData.length);
|
||||||
|
expect(p.isElementSelected(0)).toBeTruthy();
|
||||||
|
expect(p.countSelected()).toEqual(someData.length);
|
||||||
|
expect(p.allSelected()).toEqual(someData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("immutability", () => {
|
||||||
|
/*
|
||||||
|
the following should return a new crossfilter:
|
||||||
|
- addDimension()
|
||||||
|
- delDimension()
|
||||||
|
- select
|
||||||
|
*/
|
||||||
|
const p2 = payments.addDimension(
|
||||||
|
"quantity",
|
||||||
|
"scalar",
|
||||||
|
(i, data) => data[i].quantity,
|
||||||
|
Int32Array
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payments).not.toBe(p2);
|
||||||
|
const p3 = p2.select("quantity", { mode: "all" });
|
||||||
|
expect(p3).not.toBe(p2);
|
||||||
|
|
||||||
|
const p4 = p3.delDimension("quantity");
|
||||||
|
expect(p4).not.toBe(p3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("select all and none", () => {
|
||||||
|
let p = payments
|
||||||
|
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||||
|
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
|
||||||
|
.addDimension("total", "scalar", (i, d) => d[i].total, Float32Array)
|
||||||
|
.addDimension("type", "enum", (i, d) => d[i].type);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
|
||||||
|
/* expect all records to be selected - default init state */
|
||||||
|
expect(p.allSelected()).toEqual(someData);
|
||||||
|
expect(p.countSelected()).toEqual(someData.length);
|
||||||
|
expect(p.allSelectedMask()).toEqual(
|
||||||
|
new Uint8Array(someData.length).fill(1)
|
||||||
|
);
|
||||||
|
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
|
||||||
|
new Uint8Array(someData.length).fill(99)
|
||||||
|
);
|
||||||
|
for (let i = 0; i < someData.length; i += 1) {
|
||||||
|
expect(p.isElementSelected(i)).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* expect a selectAll on one dimension to change nothing */
|
||||||
|
p = p.select("tip", { mode: "all" });
|
||||||
|
expect(p.allSelected()).toEqual(someData);
|
||||||
|
|
||||||
|
/* ditto */
|
||||||
|
p = p.select("quantity", { mode: "all" });
|
||||||
|
expect(p.allSelected()).toEqual(someData);
|
||||||
|
|
||||||
|
/* select none on one dimension */
|
||||||
|
p = p.select("type", { mode: "none" });
|
||||||
|
expect(p.allSelected()).toEqual([]);
|
||||||
|
expect(p.countSelected()).toEqual(0);
|
||||||
|
expect(p.allSelectedMask()).toEqual(
|
||||||
|
new Uint8Array(someData.length).fill(0)
|
||||||
|
);
|
||||||
|
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
|
||||||
|
new Uint8Array(someData.length).fill(0)
|
||||||
|
);
|
||||||
|
for (let i = 0; i < someData.length; i += 1) {
|
||||||
|
expect(p.isElementSelected(i)).toBeFalsy();
|
||||||
|
}
|
||||||
|
|
||||||
|
p = p.select("quantity", { mode: "none" });
|
||||||
|
expect(p.allSelected()).toEqual([]);
|
||||||
|
|
||||||
|
// invert the first none; should have no effect because type is
|
||||||
|
// still not filtered.
|
||||||
|
p = p.select("quantity", { mode: "all" });
|
||||||
|
expect(p.allSelected()).toEqual([]);
|
||||||
|
|
||||||
|
/* select all of type; should select all records */
|
||||||
|
p = p.select("type", { mode: "all" });
|
||||||
|
expect(p.allSelected()).toEqual(someData);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scalar dimension", () => {
|
||||||
|
let p;
|
||||||
|
beforeEach(() => {
|
||||||
|
p = payments
|
||||||
|
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||||
|
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
|
||||||
|
.select("tip", { mode: "all" });
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
select modes: all, none, exact, range
|
||||||
|
*/
|
||||||
|
test("all", () => {
|
||||||
|
expect(p.select("quantity", { mode: "all" }).countSelected()).toEqual(
|
||||||
|
someData.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("none", () => {
|
||||||
|
expect(p.select("quantity", { mode: "none" }).countSelected()).toEqual(0);
|
||||||
|
});
|
||||||
|
test.each([[[]], [[2]], [[2, 1]], [[9, 82]], [[0, 1]]])("exact: %p", v =>
|
||||||
|
expect(
|
||||||
|
p.select("quantity", { mode: "exact", values: v }).countSelected()
|
||||||
|
).toEqual(_.filter(someData, d => v.includes(d.quantity)).length)
|
||||||
|
);
|
||||||
|
test.each([[0, 1], [1, 2], [0, 99], [99, 100000]])("range %p", (lo, hi) =>
|
||||||
|
expect(
|
||||||
|
p.select("quantity", { mode: "range", lo, hi }).countSelected()
|
||||||
|
).toEqual(
|
||||||
|
_.filter(someData, d => d.quantity >= lo && d.quantity < hi).length
|
||||||
|
)
|
||||||
|
);
|
||||||
|
test("bad mode", () => {
|
||||||
|
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enum dimension", () => {
|
||||||
|
let p;
|
||||||
|
beforeEach(() => {
|
||||||
|
p = payments.addDimension("type", "enum", (i, d) => d[i].type);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all", () => {
|
||||||
|
expect(p.select("type", { mode: "all" }).countSelected()).toEqual(
|
||||||
|
someData.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("none", () => {
|
||||||
|
expect(p.select("type", { mode: "none" }).countSelected()).toEqual(0);
|
||||||
|
});
|
||||||
|
test.each([
|
||||||
|
[[]],
|
||||||
|
[["tab"]],
|
||||||
|
[["visa"]],
|
||||||
|
[["visa", "tab"]],
|
||||||
|
[["cash", "tab", "visa"]]
|
||||||
|
])("exact: %p", v =>
|
||||||
|
expect(
|
||||||
|
p.select("type", { mode: "exact", values: v }).countSelected()
|
||||||
|
).toEqual(_.filter(someData, d => v.includes(d.type)).length)
|
||||||
|
);
|
||||||
|
test("range", () => {
|
||||||
|
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
|
||||||
|
Error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("bad mode", () => {
|
||||||
|
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spatial dimension", () => {
|
||||||
|
let p;
|
||||||
|
beforeEach(() => {
|
||||||
|
const X = someData.map(r => r.coords[0]);
|
||||||
|
const Y = someData.map(r => r.coords[1]);
|
||||||
|
p = payments.addDimension("coords", "spatial", X, Y);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all", () => {
|
||||||
|
expect(p.select("coords", { mode: "all" }).countSelected()).toEqual(
|
||||||
|
someData.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("none", () => {
|
||||||
|
expect(p.select("coords", { mode: "none" }).countSelected()).toEqual(0);
|
||||||
|
});
|
||||||
|
test.each([[0, 0, 1, 1], [0, 0, 0.5, 0.5], [0.5, 0.5, 1, 1]])(
|
||||||
|
"within-rect %d %d %d %d",
|
||||||
|
(minX, minY, maxX, maxY) => {
|
||||||
|
expect(
|
||||||
|
p
|
||||||
|
.select("coords", { mode: "within-rect", minX, minY, maxX, maxY })
|
||||||
|
.allSelected()
|
||||||
|
).toEqual(
|
||||||
|
_.filter(someData, d => {
|
||||||
|
const [x, y] = d.coords;
|
||||||
|
return minX <= x && x < maxX && minY <= y && y < maxY;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[[[0, 0], [0, 1], [1, 1], [1, 0]]],
|
||||||
|
[[[0, 0], [0, 0.5], [0.5, 0.5], [0.5, 0]]]
|
||||||
|
])("within-polygon %p", polygon => {
|
||||||
|
expect(
|
||||||
|
p.select("coords", { mode: "within-polygon", polygon }).allSelected()
|
||||||
|
).toEqual(_.filter(someData, d => polygonContains(polygon, d.coords)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("non-finite scalars", () => {
|
||||||
|
let p;
|
||||||
|
beforeEach(() => {
|
||||||
|
p = payments
|
||||||
|
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||||
|
.addDimension(
|
||||||
|
"nonFinite",
|
||||||
|
"scalar",
|
||||||
|
(i, d) => d[i].nonFinite,
|
||||||
|
Float32Array
|
||||||
|
)
|
||||||
|
.select("quantity", { mode: "all" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all or none", () => {
|
||||||
|
expect(p.select("nonFinite", { mode: "all" }).countSelected()).toEqual(
|
||||||
|
someData.length
|
||||||
|
);
|
||||||
|
expect(p.select("nonFinite", { mode: "none" }).countSelected()).toEqual(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exact", () => {
|
||||||
|
expect(
|
||||||
|
p.select("nonFinite", { mode: "exact", values: [0] }).countSelected()
|
||||||
|
).toEqual(3);
|
||||||
|
expect(
|
||||||
|
p.select("nonFinite", { mode: "exact", values: [1] }).countSelected()
|
||||||
|
).toEqual(1);
|
||||||
|
expect(
|
||||||
|
p
|
||||||
|
.select("nonFinite", {
|
||||||
|
mode: "exact",
|
||||||
|
values: [Number.POSITIVE_INFINITY]
|
||||||
|
})
|
||||||
|
.countSelected()
|
||||||
|
).toEqual(1);
|
||||||
|
expect(
|
||||||
|
p
|
||||||
|
.select("nonFinite", {
|
||||||
|
mode: "exact",
|
||||||
|
values: [Number.NEGATIVE_INFINITY]
|
||||||
|
})
|
||||||
|
.countSelected()
|
||||||
|
).toEqual(1);
|
||||||
|
expect(
|
||||||
|
p
|
||||||
|
.select("nonFinite", { mode: "exact", values: [Number.NaN] })
|
||||||
|
.countSelected()
|
||||||
|
).toEqual(4);
|
||||||
|
expect(
|
||||||
|
p
|
||||||
|
.select("nonFinite", {
|
||||||
|
mode: "exact",
|
||||||
|
values: [Number.POSITIVE_INFINITY, 0, 1, 99]
|
||||||
|
})
|
||||||
|
.countSelected()
|
||||||
|
).toEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("range", () => {
|
||||||
|
expect(
|
||||||
|
p
|
||||||
|
.select("nonFinite", {
|
||||||
|
mode: "range",
|
||||||
|
lo: 0,
|
||||||
|
hi: Number.POSITIVE_INFINITY
|
||||||
|
})
|
||||||
|
.countSelected()
|
||||||
|
).toEqual(5);
|
||||||
|
expect(
|
||||||
|
p
|
||||||
|
.select("nonFinite", {
|
||||||
|
mode: "range",
|
||||||
|
lo: 0,
|
||||||
|
hi: Number.NaN
|
||||||
|
})
|
||||||
|
.countSelected()
|
||||||
|
).toEqual(6);
|
||||||
|
expect(
|
||||||
|
p
|
||||||
|
.select("nonFinite", {
|
||||||
|
mode: "range",
|
||||||
|
lo: Number.NEGATIVE_INFINITY,
|
||||||
|
hi: Number.POSITIVE_INFINITY
|
||||||
|
})
|
||||||
|
.countSelected()
|
||||||
|
).toEqual(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
|
||||||
|
// const PositiveIntervals = require("../../src/util/typedCrossfilter/positiveIntervals");
|
||||||
|
import PositiveIntervals from "../../../src/util/typedCrossfilter/positiveIntervals";
|
||||||
|
|
||||||
|
describe("canonicalize", () => {
|
||||||
|
test("empty", () => {
|
||||||
|
expect(PositiveIntervals.canonicalize([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple, already correct", () => {
|
||||||
|
expect(PositiveIntervals.canonicalize([[0, 1]])).toEqual([[0, 1]]);
|
||||||
|
expect(PositiveIntervals.canonicalize([[0, 1], [2, 3]])).toEqual([
|
||||||
|
[0, 1],
|
||||||
|
[2, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-canonical, need to be canonicalized", () => {
|
||||||
|
expect(PositiveIntervals.canonicalize([[0, 1], [1, 2]])).toEqual([[0, 2]]);
|
||||||
|
expect(PositiveIntervals.canonicalize([[1, 2], [2, 3]])).toEqual([[1, 3]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("union", () => {
|
||||||
|
test("empty range", () => {
|
||||||
|
expect(PositiveIntervals.union([], [])).toEqual([]);
|
||||||
|
expect(PositiveIntervals.union([], [[1, 2]])).toEqual([[1, 2]]);
|
||||||
|
expect(PositiveIntervals.union([], [[1, 2], [3, 4]])).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, 4]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.union([[3, 4]], [])).toEqual([[3, 4]]);
|
||||||
|
expect(PositiveIntervals.union([[1, 2], [3, 4]], [])).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, 4]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.union([[3, 3]], [])).toEqual([[3, 3]]);
|
||||||
|
expect(PositiveIntervals.union([], [[3, 3]])).toEqual([[3, 3]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple ranges", () => {
|
||||||
|
expect(PositiveIntervals.union([[1, 2]], [[2, 3]])).toEqual([[1, 3]]);
|
||||||
|
expect(PositiveIntervals.union([[2, 3]], [[1, 2]])).toEqual([[1, 3]]);
|
||||||
|
expect(PositiveIntervals.union([[1, 2]], [[3, 4]])).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, 4]
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
PositiveIntervals.union([[1, 2], [3, 4]], [[6, 7], [19, 40]])
|
||||||
|
).toEqual([[1, 2], [3, 4], [6, 7], [19, 40]]);
|
||||||
|
expect(PositiveIntervals.union([[1, 4]], [[1, 1], [3, 4]])).toEqual([
|
||||||
|
[1, 4]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.union([[3, 3]], [[4, 4]])).toEqual([
|
||||||
|
[3, 3],
|
||||||
|
[4, 4]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("intersection", () => {
|
||||||
|
test("empty range", () => {
|
||||||
|
expect(PositiveIntervals.intersection([], [])).toEqual([]);
|
||||||
|
expect(PositiveIntervals.intersection([], [[1, 2]])).toEqual([]);
|
||||||
|
expect(PositiveIntervals.intersection([[1, 2]], [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple", () => {
|
||||||
|
expect(PositiveIntervals.intersection([[1, 2]], [[2, 3]])).toEqual([]);
|
||||||
|
expect(PositiveIntervals.intersection([[2, 3]], [[1, 2]])).toEqual([]);
|
||||||
|
expect(PositiveIntervals.intersection([[1, 10]], [[1, 10]])).toEqual([
|
||||||
|
[1, 10]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.intersection([[1, 10]], [[2, 8]])).toEqual([
|
||||||
|
[2, 8]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.intersection([[2, 8]], [[1, 10]])).toEqual([
|
||||||
|
[2, 8]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.intersection([[1, 10]], [[2, 12]])).toEqual([
|
||||||
|
[2, 10]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.intersection([[2, 12]], [[1, 10]])).toEqual([
|
||||||
|
[2, 10]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.intersection([[1, 10]], [[1, 8]])).toEqual([
|
||||||
|
[1, 8]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.intersection([[1, 8]], [[1, 10]])).toEqual([
|
||||||
|
[1, 8]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.intersection([[1, 10]], [[1, 2], [6, 9]])).toEqual(
|
||||||
|
[[1, 2], [6, 9]]
|
||||||
|
);
|
||||||
|
expect(PositiveIntervals.intersection([[0, 2638]], [[1363, 2638]])).toEqual(
|
||||||
|
[[1363, 2638]]
|
||||||
|
);
|
||||||
|
expect(PositiveIntervals.intersection([[1, 2]], [[1, 2]])).toEqual([
|
||||||
|
[1, 2]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("difference", () => {
|
||||||
|
test("empty", () => {
|
||||||
|
expect(PositiveIntervals.difference([], [])).toEqual([]);
|
||||||
|
expect(PositiveIntervals.difference([], [[1, 10]])).toEqual([]);
|
||||||
|
expect(PositiveIntervals.difference([[1, 10]], [])).toEqual([[1, 10]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple", () => {
|
||||||
|
expect(PositiveIntervals.difference([[1, 2], [3, 4]], [])).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, 4]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[5, 10]])).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, 5]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[0, 5]])).toEqual([
|
||||||
|
[5, 10]
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
PositiveIntervals.difference([[0, 2638]], [[0, 1363], [2055, 2638]])
|
||||||
|
).toEqual([[1363, 2055]]);
|
||||||
|
expect(
|
||||||
|
PositiveIntervals.difference([[0, 1363], [2055, 2638]], [[0, 2638]])
|
||||||
|
).toEqual([]);
|
||||||
|
expect(PositiveIntervals.difference([[0, 10]], [[0, 1]])).toEqual([
|
||||||
|
[1, 10]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.difference([[0, 10]], [[1, 2]])).toEqual([
|
||||||
|
[0, 1],
|
||||||
|
[2, 10]
|
||||||
|
]);
|
||||||
|
expect(PositiveIntervals.difference([[0, 10]], [[9, 10]])).toEqual([
|
||||||
|
[0, 9]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import {
|
||||||
|
sortArray,
|
||||||
|
sortIndex,
|
||||||
|
lowerBound,
|
||||||
|
upperBound,
|
||||||
|
lowerBoundIndirect,
|
||||||
|
upperBoundIndirect
|
||||||
|
} from "../../../src/util/typedCrossfilter/sort";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Sort tests should keep in mind that there are separate code
|
||||||
|
paths for:
|
||||||
|
- small vs. large arrays (insertionsort only)
|
||||||
|
- float-only typed arrays vs. other array types (non-finite handling)
|
||||||
|
- indexed vs. direct sort
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pInf = Number.POSITIVE_INFINITY;
|
||||||
|
const nInf = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
function fillRange(arr, start = 0) {
|
||||||
|
const larr = arr;
|
||||||
|
for (let i = 0, len = larr.length; i < len; i += 1) {
|
||||||
|
larr[i] = i + start;
|
||||||
|
}
|
||||||
|
return larr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRand(arr) {
|
||||||
|
for (let i = 0, len = arr.length; i < len; i += 1) {
|
||||||
|
arr[i] = Math.random();
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sortArray", () => {
|
||||||
|
describe("JS vals", () => {
|
||||||
|
[
|
||||||
|
[true, false],
|
||||||
|
["a", "b", "0", "1"],
|
||||||
|
[0, "a", true, null, undefined, 3.1415],
|
||||||
|
fillRand(new Array(1000)),
|
||||||
|
["a", NaN, null, pInf]
|
||||||
|
].map((val, idx) =>
|
||||||
|
test(`JS vals ${idx}`, () => {
|
||||||
|
expect(sortArray(val)).toMatchObject(val.sort());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("finite numbers", () => {
|
||||||
|
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
|
||||||
|
test(Type.name, () => {
|
||||||
|
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
|
||||||
|
Type.from([0, 1, 2, 3, 4, 5, 6])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
|
||||||
|
Type.from([1, 2, 3, 4, 5, 6])
|
||||||
|
);
|
||||||
|
|
||||||
|
const source = fillRand(new Type(1000));
|
||||||
|
expect(sortArray(Type.from(source))).toMatchObject(
|
||||||
|
Type.from(source).sort()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("non-finite numbers", () => {
|
||||||
|
test("inifinity", () => {
|
||||||
|
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
|
||||||
|
new Float32Array([nInf, 0, 1, 2, pInf])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
sortArray(new Float32Array([pInf, nInf, pInf, nInf]))
|
||||||
|
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf]));
|
||||||
|
expect(
|
||||||
|
sortArray(new Float32Array([pInf, nInf, pInf, nInf, pInf]))
|
||||||
|
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf, pInf]));
|
||||||
|
expect(
|
||||||
|
sortArray(
|
||||||
|
new Float32Array(100).fill(Infinity, 0, 50).fill(-Infinity, 50, 100)
|
||||||
|
)
|
||||||
|
).toMatchObject(
|
||||||
|
new Float32Array(100).fill(-Infinity, 0, 50).fill(Infinity, 50, 100)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("NaN", () => {
|
||||||
|
expect(sortArray(new Float64Array([NaN, 2, 1, 0]))).toMatchObject(
|
||||||
|
new Float64Array([0, 1, 2, NaN])
|
||||||
|
);
|
||||||
|
expect(sortArray(new Float32Array([NaN, 2, 1, 0]))).toMatchObject(
|
||||||
|
new Float32Array([0, 1, 2, NaN])
|
||||||
|
);
|
||||||
|
expect(sortArray(new Float32Array([NaN, 2, NaN, 1, 0]))).toMatchObject(
|
||||||
|
new Float32Array([0, 1, 2, NaN, NaN])
|
||||||
|
);
|
||||||
|
expect(sortArray(new Float32Array([NaN, 2, 1, NaN, 0]))).toMatchObject(
|
||||||
|
new Float32Array([0, 1, 2, NaN, NaN])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
sortArray(fillRange(new Float32Array(100)).fill(NaN, 0, 10))
|
||||||
|
).toMatchObject(fillRange(new Float32Array(100), 10).fill(NaN, 90, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mixed numbers", () => {
|
||||||
|
expect(
|
||||||
|
sortArray(new Float32Array([NaN, pInf, nInf, NaN, NaN]))
|
||||||
|
).toMatchObject(new Float32Array([nInf, pInf, NaN, NaN, NaN]));
|
||||||
|
expect(
|
||||||
|
sortArray(new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]))
|
||||||
|
).toMatchObject(new Float32Array([nInf, 1, 2, pInf, NaN, NaN, NaN]));
|
||||||
|
expect(
|
||||||
|
sortArray(new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]))
|
||||||
|
).toMatchObject(new Float32Array([nInf, 0, 1, 2, pInf, NaN, NaN]));
|
||||||
|
expect(
|
||||||
|
sortArray(
|
||||||
|
fillRange(new Float32Array(100))
|
||||||
|
.fill(NaN, 0, 10)
|
||||||
|
.fill(Infinity, 10, 20)
|
||||||
|
)
|
||||||
|
).toMatchObject(
|
||||||
|
fillRange(new Float32Array(100), 20)
|
||||||
|
.fill(Infinity, 80, 90)
|
||||||
|
.fill(NaN, 90, 100)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortIndex", () => {
|
||||||
|
describe("finite numbers", () => {
|
||||||
|
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
|
||||||
|
test(Type.name, () => {
|
||||||
|
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
|
||||||
|
const index1 = fillRange(new Uint32Array(source1.length));
|
||||||
|
expect(sortIndex(index1, source1)).toMatchObject(
|
||||||
|
index1.sort((a, b) => source1[a] - source1[b])
|
||||||
|
);
|
||||||
|
|
||||||
|
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
|
||||||
|
const index2 = fillRange(new Uint32Array(source2.length));
|
||||||
|
expect(sortIndex(index2, source2)).toMatchObject(
|
||||||
|
index2.sort((a, b) => source1[a] - source1[b])
|
||||||
|
);
|
||||||
|
|
||||||
|
const source3 = fillRand(new Type(1000));
|
||||||
|
const index3 = fillRange(new Uint32Array(source3.length));
|
||||||
|
expect(sortIndex(index3, source3)).toMatchObject(
|
||||||
|
index3.sort((a, b) => source1[a] - source1[b])
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("non-finite numbers", () => {
|
||||||
|
test("mixed numbers", () => {
|
||||||
|
const source1 = new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]);
|
||||||
|
const index1 = fillRange(new Uint32Array(source1.length));
|
||||||
|
expect(sortIndex(index1, source1)).toMatchObject(
|
||||||
|
new Uint32Array([2, 4, 6, 1, 0, 3, 5])
|
||||||
|
);
|
||||||
|
|
||||||
|
const source2 = new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]);
|
||||||
|
const index2 = fillRange(new Uint32Array(source2.length));
|
||||||
|
expect(sortIndex(index2, source2)).toMatchObject(
|
||||||
|
new Uint32Array([2, 3, 4, 6, 1, 0, 5])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lowerBound", () => {
|
||||||
|
test("non-float path", () => {
|
||||||
|
expect(lowerBound([], 0, 0, 0)).toEqual(0);
|
||||||
|
|
||||||
|
expect(lowerBound([0, 1, 2, 3], -1, 0, 4)).toEqual(0);
|
||||||
|
expect(lowerBound([0, 1, 2, 3], 0, 0, 4)).toEqual(0);
|
||||||
|
expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1);
|
||||||
|
expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3);
|
||||||
|
expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4);
|
||||||
|
|
||||||
|
expect(lowerBound([0, 1, 2, 3, 4], -1, 0, 5)).toEqual(0);
|
||||||
|
expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0);
|
||||||
|
expect(lowerBound([0, 1, 2, 3, 4], 2, 0, 5)).toEqual(2);
|
||||||
|
expect(lowerBound([0, 1, 2, 3, 4], 4, 0, 5)).toEqual(4);
|
||||||
|
expect(lowerBound([0, 1, 2, 3, 4], 5, 0, 5)).toEqual(5);
|
||||||
|
|
||||||
|
expect(lowerBound([0, 2, 4, 6, 8], 5, 0, 5)).toEqual(3);
|
||||||
|
expect(lowerBound([0, 2, 2, 2, 8], 5, 0, 5)).toEqual(4);
|
||||||
|
|
||||||
|
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 3, 2, 4)).toEqual(3);
|
||||||
|
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 99, 2, 4)).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("float path, finites", () => {
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
|
||||||
|
|
||||||
|
expect(lowerBound(new Float32Array([]), 0, 0, 0)).toEqual(0);
|
||||||
|
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3]), -1, 0, 4)).toEqual(0);
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 0, 0, 4)).toEqual(0);
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 3, 0, 4)).toEqual(3);
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 4)).toEqual(4);
|
||||||
|
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), -1, 0, 5)).toEqual(0);
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0);
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 2, 0, 5)).toEqual(2);
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 4, 0, 5)).toEqual(4);
|
||||||
|
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 5, 0, 5)).toEqual(5);
|
||||||
|
|
||||||
|
expect(lowerBound(new Float32Array([0, 2, 4, 6, 8]), 5, 0, 5)).toEqual(3);
|
||||||
|
expect(lowerBound(new Float32Array([0, 2, 2, 2, 8]), 5, 0, 5)).toEqual(4);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 3, 2, 4)
|
||||||
|
).toEqual(3);
|
||||||
|
expect(
|
||||||
|
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 99, 2, 4)
|
||||||
|
).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("float path, non-finite", () => {
|
||||||
|
expect(
|
||||||
|
lowerBound(
|
||||||
|
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
|
||||||
|
-Infinity,
|
||||||
|
0,
|
||||||
|
5
|
||||||
|
)
|
||||||
|
).toEqual(0);
|
||||||
|
expect(
|
||||||
|
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 0, 0, 5)
|
||||||
|
).toEqual(1);
|
||||||
|
expect(
|
||||||
|
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 1, 0, 5)
|
||||||
|
).toEqual(2);
|
||||||
|
expect(
|
||||||
|
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 2, 0, 5)
|
||||||
|
).toEqual(3);
|
||||||
|
expect(
|
||||||
|
lowerBound(
|
||||||
|
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
|
||||||
|
Infinity,
|
||||||
|
0,
|
||||||
|
5
|
||||||
|
)
|
||||||
|
).toEqual(3);
|
||||||
|
expect(
|
||||||
|
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), NaN, 0, 5)
|
||||||
|
).toEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
fillRange,
|
||||||
|
sliceByIndex,
|
||||||
|
makeSortIndex
|
||||||
|
} from "../../../src/util/typedCrossfilter/util";
|
||||||
|
|
||||||
|
describe("fillRange", () => {
|
||||||
|
test("Array", () => {
|
||||||
|
expect(fillRange(new Array(6))).toMatchObject([0, 1, 2, 3, 4, 5]);
|
||||||
|
expect(fillRange(new Array(4), 1)).toMatchObject([1, 2, 3, 4]);
|
||||||
|
expect(fillRange([])).toMatchObject([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Uint32Array", () => {
|
||||||
|
expect(fillRange(new Uint32Array(6))).toMatchObject(
|
||||||
|
new Uint32Array([0, 1, 2, 3, 4, 5])
|
||||||
|
);
|
||||||
|
expect(fillRange(new Array(4), 1)).toMatchObject(
|
||||||
|
new Uint32Array([1, 2, 3, 4])
|
||||||
|
);
|
||||||
|
expect(fillRange([])).toMatchObject(new Uint32Array([]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sliceByIndex", () => {
|
||||||
|
test("Array", () => {
|
||||||
|
expect(sliceByIndex([0, 1, 2, 3, 4], [0, 1, 2])).toMatchObject([0, 1, 2]);
|
||||||
|
expect(sliceByIndex([0, 1, 2, 3, 4], [2, 1, 0])).toMatchObject([2, 1, 0]);
|
||||||
|
expect(sliceByIndex([0, 1, 2, 3, 4], [])).toMatchObject([]);
|
||||||
|
expect(sliceByIndex([], [])).toMatchObject([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Uint32Array", () => {
|
||||||
|
expect(
|
||||||
|
sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([0, 1, 2]))
|
||||||
|
).toMatchObject([0, 1, 2]);
|
||||||
|
expect(
|
||||||
|
sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([2, 1, 0]))
|
||||||
|
).toMatchObject([2, 1, 0]);
|
||||||
|
expect(sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([]))).toMatchObject(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
expect(sliceByIndex([], new Uint32Array([]))).toMatchObject([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sliceByIndex(new Uint32Array([0, 1, 2, 3, 4]), new Uint32Array([0, 1, 2]))
|
||||||
|
).toMatchObject(new Uint32Array([0, 1, 2]));
|
||||||
|
expect(
|
||||||
|
sliceByIndex(new Uint32Array([0, 1, 2, 3, 4]), new Uint32Array([2, 1, 0]))
|
||||||
|
).toMatchObject(new Uint32Array([2, 1, 0]));
|
||||||
|
expect(
|
||||||
|
sliceByIndex(
|
||||||
|
new Uint32Array([0, 1, 2, 3, 4]),
|
||||||
|
new Uint32Array(new Uint32Array([]))
|
||||||
|
)
|
||||||
|
).toMatchObject(new Uint32Array([]));
|
||||||
|
expect(
|
||||||
|
sliceByIndex(new Uint32Array([]), new Uint32Array([]))
|
||||||
|
).toMatchObject(new Uint32Array([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Float32Array", () => {
|
||||||
|
expect(
|
||||||
|
sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [0, 1, 2])
|
||||||
|
).toMatchObject(new Float32Array([0, 1, 2]));
|
||||||
|
expect(
|
||||||
|
sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [2, 1, 0])
|
||||||
|
).toMatchObject(new Float32Array([2, 1, 0]));
|
||||||
|
expect(sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [])).toMatchObject(
|
||||||
|
new Float32Array([])
|
||||||
|
);
|
||||||
|
expect(sliceByIndex(new Float32Array([]), [])).toMatchObject(
|
||||||
|
new Float32Array([])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("makeSortIndex", () => {
|
||||||
|
test("Array", () => {
|
||||||
|
expect(makeSortIndex([3, 2, 1, 0])).toMatchObject(
|
||||||
|
new Uint32Array([3, 2, 1, 0])
|
||||||
|
);
|
||||||
|
expect(makeSortIndex([3, 2, 1, 0, 4])).toMatchObject(
|
||||||
|
new Uint32Array([3, 2, 1, 0, 4])
|
||||||
|
);
|
||||||
|
expect(makeSortIndex([])).toMatchObject(new Uint32Array([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Float32Array", () => {
|
||||||
|
expect(makeSortIndex(new Float32Array([3, 2, 1, 0]))).toMatchObject(
|
||||||
|
new Uint32Array([3, 2, 1, 0])
|
||||||
|
);
|
||||||
|
expect(makeSortIndex(new Float32Array([3, 2, 1, 0, 4]))).toMatchObject(
|
||||||
|
new Uint32Array([3, 2, 1, 0, 4])
|
||||||
|
);
|
||||||
|
expect(makeSortIndex(new Float32Array([]))).toMatchObject(
|
||||||
|
new Uint32Array([])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Int32Array", () => {
|
||||||
|
expect(makeSortIndex(new Int32Array([3, 2, 1, 0]))).toMatchObject(
|
||||||
|
new Uint32Array([3, 2, 1, 0])
|
||||||
|
);
|
||||||
|
expect(makeSortIndex(new Int32Array([3, 2, 1, 0, 4]))).toMatchObject(
|
||||||
|
new Uint32Array([3, 2, 1, 0, 4])
|
||||||
|
);
|
||||||
|
expect(makeSortIndex(new Int32Array([]))).toMatchObject(
|
||||||
|
new Uint32Array([])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
babelrc: false,
|
||||||
|
cacheDirectory: true,
|
||||||
|
presets: [
|
||||||
|
["modern-browsers", { loose: true, modules: false }],
|
||||||
|
"@babel/preset-react"
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
"@babel/plugin-proposal-function-bind",
|
||||||
|
"@babel/plugin-proposal-class-properties",
|
||||||
|
["@babel/plugin-proposal-decorators", { legacy: true }],
|
||||||
|
"@babel/plugin-proposal-export-namespace-from",
|
||||||
|
"@babel/plugin-proposal-optional-chaining",
|
||||||
|
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
module.exports = {
|
||||||
|
babelrc: false,
|
||||||
|
presets: [
|
||||||
|
["modern-browsers", { loose: true, modules: false }],
|
||||||
|
"@babel/preset-react"
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
"@babel/plugin-proposal-function-bind",
|
||||||
|
"@babel/plugin-proposal-class-properties",
|
||||||
|
["@babel/plugin-proposal-decorators", { legacy: true }],
|
||||||
|
"@babel/plugin-proposal-export-namespace-from",
|
||||||
|
"@babel/plugin-transform-react-constant-elements",
|
||||||
|
"@babel/plugin-transform-runtime",
|
||||||
|
"@babel/plugin-proposal-optional-chaining",
|
||||||
|
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parser: "babel-eslint",
|
||||||
|
extends: ["airbnb", "prettier", "prettier/react"],
|
||||||
|
env: { browser: true, commonjs: true, es6: true },
|
||||||
|
globals: { expect: true },
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2017,
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
generators: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-magic-numbers": "off",
|
||||||
|
"no-nested-ternary": "off",
|
||||||
|
"func-style": "off",
|
||||||
|
"arrow-parens": "off",
|
||||||
|
"no-use-before-define": "off",
|
||||||
|
"react/jsx-filename-extension": "off",
|
||||||
|
"comma-dangle": "off",
|
||||||
|
"no-underscore-dangle": "off",
|
||||||
|
quotes: ["error", "double"],
|
||||||
|
"implicit-arrow-linebreak": "off",
|
||||||
|
"operator-linebreak": [
|
||||||
|
"error",
|
||||||
|
"after",
|
||||||
|
{ overrides: { "?": "before", ":": "before" } }
|
||||||
|
],
|
||||||
|
"no-console": "off",
|
||||||
|
"spaced-comment": ["error", "always", { exceptions: ["*"] }],
|
||||||
|
"no-param-reassign": "off",
|
||||||
|
"object-curly-newline": ["error", { consistent: true }],
|
||||||
|
"react/prop-types": [0],
|
||||||
|
"space-before-function-paren": "off",
|
||||||
|
"function-paren-newline": "off",
|
||||||
|
"prefer-destructuring": ["error", { object: true, array: false }]
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["**/*.test.js"],
|
||||||
|
env: {
|
||||||
|
jest: true // now **/*.test.js files' env has both es6 *and* jest
|
||||||
|
},
|
||||||
|
// Can't extend in overrides: https://github.com/eslint/eslint/issues/8813
|
||||||
|
// "extends": ["plugin:jest/recommended"]
|
||||||
|
plugins: ["jest"],
|
||||||
|
rules: {
|
||||||
|
"jest/no-disabled-tests": "warn",
|
||||||
|
"jest/no-focused-tests": "error",
|
||||||
|
"jest/no-identical-title": "error",
|
||||||
|
"jest/prefer-to-have-length": "warn",
|
||||||
|
"jest/valid-expect": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
+37
-28
@@ -1,41 +1,61 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const autoprefixer = require("autoprefixer");
|
|
||||||
const webpack = require("webpack");
|
const webpack = require("webpack");
|
||||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
|
|
||||||
const src = path.resolve("src");
|
const src = path.resolve("src");
|
||||||
const nodeModules = path.resolve("node_modules");
|
const nodeModules = path.resolve("node_modules");
|
||||||
|
|
||||||
|
const babelOptions = require("../babel/babel.dev");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
mode: "development",
|
||||||
devtool: "eval",
|
devtool: "eval",
|
||||||
entry: [
|
entry: ["./src/index"],
|
||||||
"webpack-hot-middleware/client?quiet=true&noInfo=true",
|
|
||||||
require.resolve("react-hot-loader/patch"),
|
|
||||||
require.resolve("../polyfills/polyfills"),
|
|
||||||
path.join(src, "index")
|
|
||||||
],
|
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve("build"),
|
path: path.resolve("build"),
|
||||||
pathinfo: true,
|
pathinfo: true,
|
||||||
filename: "static/js/bundle.js",
|
filename: "static/js/bundle.js",
|
||||||
publicPath: "/"
|
publicPath: "/"
|
||||||
},
|
},
|
||||||
resolve: { extensions: [".js", ".json"] },
|
|
||||||
module: {
|
module: {
|
||||||
loaders: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.js$/,
|
test: /\.js$/,
|
||||||
include: src,
|
include: src,
|
||||||
loader: "babel-loader",
|
loader: "babel-loader",
|
||||||
query: require("../babel/babel.dev")
|
options: babelOptions
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.css$/,
|
test: /\.css$/,
|
||||||
include: [src, nodeModules],
|
include: src,
|
||||||
loader:
|
exclude: [path.resolve(src, "index.css")],
|
||||||
"style-loader!css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader"
|
loader: [
|
||||||
|
{
|
||||||
|
loader: "style-loader"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: "css-loader",
|
||||||
|
options: {
|
||||||
|
modules: true,
|
||||||
|
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /index\.css$/,
|
||||||
|
include: [path.resolve(src, "index.css")],
|
||||||
|
loader: [
|
||||||
|
{
|
||||||
|
loader: "style-loader"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: "css-loader"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
{ test: /\.json$/, include: [src, nodeModules], loader: "json-loader" },
|
{ test: /\.json$/, include: [src, nodeModules], loader: "json-loader" },
|
||||||
{
|
{
|
||||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
||||||
@@ -57,20 +77,9 @@ module.exports = {
|
|||||||
template: path.resolve("index.html"),
|
template: path.resolve("index.html"),
|
||||||
favicon: path.resolve("favicon.png")
|
favicon: path.resolve("favicon.png")
|
||||||
}),
|
}),
|
||||||
new webpack.LoaderOptionsPlugin({
|
new webpack.NoEmitOnErrorsPlugin(),
|
||||||
options: {
|
new webpack.DefinePlugin({
|
||||||
eslint: {
|
__REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })"
|
||||||
configFile: path.resolve("./configuration/eslint/eslint.js"),
|
})
|
||||||
useEslintrc: false
|
|
||||||
},
|
|
||||||
postcss() {
|
|
||||||
return [autoprefixer];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
new webpack.DefinePlugin({ "process.env.NODE_ENV": '"development"' }),
|
|
||||||
// Note: only CSS is currently hot reloaded
|
|
||||||
new webpack.HotModuleReplacementPlugin(),
|
|
||||||
new webpack.NoEmitOnErrorsPlugin()
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
+46
-33
@@ -1,46 +1,64 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const autoprefixer = require("autoprefixer");
|
|
||||||
const webpack = require("webpack");
|
|
||||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
const ExtractTextPlugin = require("extract-text-webpack-plugin");
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
|
const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
|
||||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
|
||||||
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
|
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
|
||||||
const MinifyPlugin = require("babel-minify-webpack-plugin");
|
|
||||||
|
|
||||||
const src = path.resolve("src");
|
const src = path.resolve("src");
|
||||||
const nodeModules = path.resolve("node_modules");
|
const nodeModules = path.resolve("node_modules");
|
||||||
|
|
||||||
const publicPath = "/";
|
const babelOptions = require("../babel/babel.prod");
|
||||||
|
|
||||||
|
const publicPath = "";
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
mode: "production",
|
||||||
bail: true,
|
bail: true,
|
||||||
|
cache: false,
|
||||||
devtool: "cheap-source-map",
|
devtool: "cheap-source-map",
|
||||||
entry: [require.resolve("../polyfills/polyfills"), path.join(src, "index")],
|
entry: ["./src/index.js"],
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve("build"),
|
path: path.resolve("build"),
|
||||||
filename: "static/js/[name].[chunkhash:8].js",
|
filename: "static/js/[name].[chunkhash:8].js",
|
||||||
chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
|
|
||||||
publicPath
|
publicPath
|
||||||
},
|
},
|
||||||
resolve: { extensions: [".js", ".json"] },
|
|
||||||
module: {
|
module: {
|
||||||
loaders: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.js$/,
|
test: /\.js$/,
|
||||||
include: src,
|
include: src,
|
||||||
loader: "babel-loader",
|
loader: "babel-loader",
|
||||||
query: require("../babel/babel.prod")
|
options: babelOptions
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.css$/,
|
test: /\.css$/,
|
||||||
include: [src, nodeModules],
|
include: src,
|
||||||
loader: ExtractTextPlugin.extract({
|
exclude: [path.resolve(src, "index.css")],
|
||||||
fallbackLoader: "style-loader",
|
use: [
|
||||||
loader:
|
MiniCssExtractPlugin.loader,
|
||||||
"css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]-autoprefixer!postcss-loader"
|
{
|
||||||
})
|
loader: "css-loader",
|
||||||
|
options: {
|
||||||
|
modules: true,
|
||||||
|
importLoaders: 1,
|
||||||
|
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /index\.css$/,
|
||||||
|
include: [path.resolve(src, "index.css")],
|
||||||
|
use: [
|
||||||
|
MiniCssExtractPlugin.loader,
|
||||||
|
{
|
||||||
|
loader: "css-loader",
|
||||||
|
options: {
|
||||||
|
importLoaders: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.json$/,
|
test: /\.json$/,
|
||||||
@@ -52,7 +70,10 @@ module.exports = {
|
|||||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
||||||
include: [src, nodeModules],
|
include: [src, nodeModules],
|
||||||
loader: "file-loader",
|
loader: "file-loader",
|
||||||
query: { name: "static/media/[name].[hash:8].[ext]" }
|
options: {
|
||||||
|
name: "[name].[hash:8].[ext]",
|
||||||
|
outputPath: "static/media/"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.(mp4|webm)(\?.*)?$/,
|
test: /\.(mp4|webm)(\?.*)?$/,
|
||||||
@@ -83,24 +104,16 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackInlineSourcePlugin(),
|
new HtmlWebpackInlineSourcePlugin(),
|
||||||
new webpack.LoaderOptionsPlugin({
|
new MiniCssExtractPlugin({
|
||||||
options: {
|
filename: "static/css/[name].[contenthash:8].css"
|
||||||
eslint: {
|
|
||||||
configFile: path.resolve("./configuration/eslint/eslint.js"),
|
|
||||||
useEslintrc: false
|
|
||||||
},
|
|
||||||
postcss() {
|
|
||||||
return [autoprefixer];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
new webpack.DefinePlugin({ "process.env.NODE_ENV": '"production"' }),
|
|
||||||
new webpack.optimize.OccurrenceOrderPlugin(),
|
|
||||||
new MinifyPlugin(),
|
|
||||||
new ExtractTextPlugin("static/css/[name].[contenthash:8].css"),
|
|
||||||
new SWPrecacheWebpackPlugin({
|
new SWPrecacheWebpackPlugin({
|
||||||
cacheId: "cellxgene",
|
cacheId: "cellxgene",
|
||||||
filename: "service-worker.js"
|
filename: "service-worker.js"
|
||||||
})
|
})
|
||||||
]
|
],
|
||||||
|
performance: {
|
||||||
|
maxEntrypointSize: 2000000,
|
||||||
|
maxAssetSize: 2000000
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -3,12 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Cellx</title>
|
<title>cellxgene</title>
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:400,400i,700" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input {
|
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
|
||||||
font-family: 'Roboto','Helvetica Neue','Helvetica','Arial',sans-serif;
|
font-family: 'Roboto Condensed','Helvetica Neue','Helvetica','Arial',sans-serif;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>, please <strong>enable JS</strong> to make this app work.</noscript>
|
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>, please <strong>enable JS</strong> to make this app work.</noscript>
|
||||||
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.js"></script>
|
|
||||||
<script src="//d3js.org/d3-scale-chromatic.v0.3.min.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>cellxgene</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
|
||||||
|
font-family: 'Roboto Condensed', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.CELLXGENE = {};
|
||||||
|
window.CELLXGENE.API = {
|
||||||
|
prefix: window.location.href + "api/",
|
||||||
|
version: "v0.2/"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>,
|
||||||
|
please <strong>enable JS</strong> to make this app work.
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<div id="root"></div>
|
||||||
|
{% for script in SCRIPTS %}
|
||||||
|
<script type="text/javascript" src="{{script | safe}}"></script>
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+15327
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
|||||||
|
{
|
||||||
|
"name": "cellxgene",
|
||||||
|
"version": "0.9.2",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
|
||||||
|
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
||||||
|
"scripts": {
|
||||||
|
"backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ",
|
||||||
|
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
|
||||||
|
"clean": "rimraf build",
|
||||||
|
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
|
||||||
|
"e2e": "jest --verbose false --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"smoke-test": "start-server-and-test start-server-for-test :5000 e2e",
|
||||||
|
"start": "node server/development.js",
|
||||||
|
"start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad",
|
||||||
|
"test": "jest",
|
||||||
|
"unit-test": "jest --testPathIgnorePatterns e2e"
|
||||||
|
},
|
||||||
|
"engineStrict": true,
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=3.0.0"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "./configuration/eslint/eslint.js"
|
||||||
|
},
|
||||||
|
"eslintIgnore": [
|
||||||
|
"src/util/stateManager/matrix_generated.js"
|
||||||
|
],
|
||||||
|
"resolutions": {
|
||||||
|
"eslint-scope": "3.7.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@blueprintjs/core": "^3.15.0",
|
||||||
|
"@blueprintjs/icons": "^3.3.0",
|
||||||
|
"@blueprintjs/select": "^3.8.0",
|
||||||
|
"canvas-fit": "^1.5.0",
|
||||||
|
"d3": "^4.10.0",
|
||||||
|
"d3-scale-chromatic": "^1.3.0",
|
||||||
|
"flatbuffers": "^1.10.2",
|
||||||
|
"font-color-contrast": "^1.0.3",
|
||||||
|
"fuzzysort": "^1.1.4",
|
||||||
|
"gl-mat4": "^1.1.4",
|
||||||
|
"gl-matrix": "^2.7.1",
|
||||||
|
"is-number": "^7.0.0",
|
||||||
|
"key-pressed": "0.0.1",
|
||||||
|
"lodash": "^4.17.4",
|
||||||
|
"memoize-one": "^4.0.0",
|
||||||
|
"mouse-position": "^2.0.1",
|
||||||
|
"mouse-pressed": "^1.0.0",
|
||||||
|
"normalize.css": "^8.0.0",
|
||||||
|
"orbit-camera": "^1.0.0",
|
||||||
|
"query-string": "^6.1.0",
|
||||||
|
"react": "^16.6.0",
|
||||||
|
"react-autocomplete": "^1.7.2",
|
||||||
|
"react-dom": "^16.6.0",
|
||||||
|
"react-helmet": "^5.2.0",
|
||||||
|
"react-icons": "^3.2.2",
|
||||||
|
"react-redux": "^5.1.0",
|
||||||
|
"redux": "^4.0.1",
|
||||||
|
"redux-devtools-extension": "^2.13.5",
|
||||||
|
"redux-thunk": "^2.2.0",
|
||||||
|
"regl": "^1.3.9",
|
||||||
|
"scroll-speed": "^1.0.0",
|
||||||
|
"urijs": "^1.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.1.5",
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.0.0",
|
||||||
|
"@babel/plugin-proposal-decorators": "^7.0.0",
|
||||||
|
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
|
||||||
|
"@babel/plugin-proposal-function-bind": "^7.0.0",
|
||||||
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.2.0",
|
||||||
|
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
|
||||||
|
"@babel/plugin-transform-react-constant-elements": "^7.0.0",
|
||||||
|
"@babel/plugin-transform-runtime": "^7.1.0",
|
||||||
|
"@babel/preset-env": "^7.1.5",
|
||||||
|
"@babel/preset-react": "^7.0.0",
|
||||||
|
"@babel/register": "^7.0.0",
|
||||||
|
"@babel/runtime": "^7.1.5",
|
||||||
|
"babel-core": "^7.0.0-bridge.0",
|
||||||
|
"babel-eslint": "^10.0.1",
|
||||||
|
"babel-jest": "^23.6.0",
|
||||||
|
"babel-loader": "^8.0.0",
|
||||||
|
"babel-preset-modern-browsers": "^12.0.0",
|
||||||
|
"chalk": "^2.4.2",
|
||||||
|
"connect-history-api-fallback": "^1.6.0",
|
||||||
|
"copy-webpack-plugin": "^4.6.0",
|
||||||
|
"css-loader": "^1.0.1",
|
||||||
|
"eslint": "^5.13.0",
|
||||||
|
"eslint-config-airbnb": "^17.1.0",
|
||||||
|
"eslint-config-prettier": "^4.0.0",
|
||||||
|
"eslint-loader": "^2.1.2",
|
||||||
|
"eslint-plugin-filenames": "^1.3.2",
|
||||||
|
"eslint-plugin-import": "^2.16.0",
|
||||||
|
"eslint-plugin-jest": "^22.2.2",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.2.1",
|
||||||
|
"eslint-plugin-react": "^7.12.4",
|
||||||
|
"express": "^4.14.0",
|
||||||
|
"file-loader": "^2.0.0",
|
||||||
|
"html-webpack-inline-source-plugin": "0.0.10",
|
||||||
|
"html-webpack-plugin": "^3.2.0",
|
||||||
|
"jest": "^24.1.0",
|
||||||
|
"jest-puppeteer": "^4.1.0",
|
||||||
|
"json-loader": "^0.5.4",
|
||||||
|
"mini-css-extract-plugin": "^0.4.1",
|
||||||
|
"puppeteer": "^1.12.1",
|
||||||
|
"rimraf": "^2.6.3",
|
||||||
|
"serve-favicon": "^2.3.0",
|
||||||
|
"start-server-and-test": "^1.7.11",
|
||||||
|
"style-loader": "^0.23.1",
|
||||||
|
"sw-precache-webpack-plugin": "^0.11.5",
|
||||||
|
"url-loader": "^1.1.0",
|
||||||
|
"webpack": "^4.25.1",
|
||||||
|
"webpack-cli": "^3.1.0",
|
||||||
|
"webpack-dev-middleware": "^3.1.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"testMatch": [
|
||||||
|
"**/__tests__/**/?(*.)(spec|test).js?(x)"
|
||||||
|
],
|
||||||
|
"testURL": "http://localhost/",
|
||||||
|
"setupFiles": [
|
||||||
|
"./__tests__/setupMissingGlobals.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"babel": {
|
||||||
|
"env": {
|
||||||
|
"test": {
|
||||||
|
"presets": [
|
||||||
|
"@babel/preset-env",
|
||||||
|
"@babel/preset-react"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@babel/plugin-proposal-function-bind",
|
||||||
|
"@babel/plugin-proposal-class-properties",
|
||||||
|
[
|
||||||
|
"@babel/plugin-proposal-decorators",
|
||||||
|
{
|
||||||
|
"legacy": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@babel/plugin-proposal-export-namespace-from",
|
||||||
|
"@babel/plugin-transform-react-constant-elements",
|
||||||
|
"@babel/plugin-transform-runtime"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,13 +32,11 @@ app.use(historyApiFallback({ verbose: false }));
|
|||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
require("webpack-dev-middleware")(compiler, {
|
require("webpack-dev-middleware")(compiler, {
|
||||||
noInfo: true,
|
logLevel: "warn",
|
||||||
publicPath: config.output.publicPath
|
publicPath: config.output.publicPath
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use(require("webpack-hot-middleware")(compiler));
|
|
||||||
|
|
||||||
app.use(favicon("./favicon.png"));
|
app.use(favicon("./favicon.png"));
|
||||||
|
|
||||||
app.get("*", (req, res) => {
|
app.get("*", (req, res) => {
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import _ from "lodash";
|
||||||
|
import * as globals from "../globals";
|
||||||
|
import { Universe } from "../util/stateManager";
|
||||||
|
import {
|
||||||
|
catchErrorsWrap,
|
||||||
|
doJsonRequest,
|
||||||
|
doBinaryRequest,
|
||||||
|
dispatchNetworkErrorMessageToUser
|
||||||
|
} from "../util/actionHelpers";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Bootstrap application with the initial data loading.
|
||||||
|
* /config - application configuration
|
||||||
|
* /schema - schema of dataframe
|
||||||
|
* /annotations - all metadata annotation
|
||||||
|
* /layout - all default layout
|
||||||
|
*/
|
||||||
|
const doInitialDataLoad = () =>
|
||||||
|
catchErrorsWrap(async dispatch => {
|
||||||
|
dispatch({ type: "initial data load start" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestJson = _(["config", "schema"])
|
||||||
|
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
|
||||||
|
.map(url => doJsonRequest(url))
|
||||||
|
.value();
|
||||||
|
const requestBinary = _([
|
||||||
|
"annotations/obs",
|
||||||
|
"annotations/var?annotation-name=name",
|
||||||
|
"layout/obs"
|
||||||
|
])
|
||||||
|
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
|
||||||
|
.map(url => doBinaryRequest(url))
|
||||||
|
.value();
|
||||||
|
|
||||||
|
const results = await Promise.all(_.concat(requestJson, requestBinary));
|
||||||
|
|
||||||
|
/* set config defaults */
|
||||||
|
const config = { ...globals.configDefaults, ...results[0].config };
|
||||||
|
const [, schema, obsAnno, varAnno, obsLayout] = [...results];
|
||||||
|
const universe = Universe.createUniverseFromResponse(
|
||||||
|
config,
|
||||||
|
schema,
|
||||||
|
obsAnno,
|
||||||
|
varAnno,
|
||||||
|
obsLayout
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "configuration load complete",
|
||||||
|
config
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "initial data load complete (universe exists)",
|
||||||
|
universe
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({ type: "initial data load error", error });
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Set the view (world) to current selection. Placeholder for an async action
|
||||||
|
which also does re-layout.
|
||||||
|
*/
|
||||||
|
const regraph = () => (dispatch, getState) => {
|
||||||
|
const { universe, world, crossfilter } = getState();
|
||||||
|
dispatch({
|
||||||
|
type: "set World to current selection",
|
||||||
|
universe,
|
||||||
|
world,
|
||||||
|
crossfilter
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Throws
|
||||||
|
const dispatchExpressionErrors = (dispatch, res) => {
|
||||||
|
const msg = `Unexpected HTTP response while fetching expression data ${
|
||||||
|
res.status
|
||||||
|
}, ${res.statusText}`;
|
||||||
|
dispatchNetworkErrorMessageToUser(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Fetch expression vectors for each gene in genes. This is NOT an action
|
||||||
|
function, but rather a helper to be called from an action helper that
|
||||||
|
needs expression data.
|
||||||
|
|
||||||
|
Transparently utilizes cached data if it is already present.
|
||||||
|
*/
|
||||||
|
async function _doRequestExpressionData(dispatch, getState, genes) {
|
||||||
|
/* helper for this function only */
|
||||||
|
const fetchData = async geneNames => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${globals.API.prefix}${globals.API.version}data/var`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
filter: {
|
||||||
|
var: {
|
||||||
|
annotation_value: [{ name: "name", values: geneNames }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
headers: new Headers({
|
||||||
|
accept: "application/octet-stream",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
res.headers.get("Content-Type") !== "application/octet-stream"
|
||||||
|
) {
|
||||||
|
// WILL throw
|
||||||
|
return dispatchExpressionErrors(dispatch, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.arrayBuffer();
|
||||||
|
return Universe.convertDataFBStoObject(universe, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
const { universe } = state;
|
||||||
|
/* preload data already in cache */
|
||||||
|
let expressionData = _.transform(
|
||||||
|
genes,
|
||||||
|
(expData, g) => {
|
||||||
|
const data = universe.varData.col(g);
|
||||||
|
if (data) {
|
||||||
|
expData[g] = data.asArray();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
); // --> { gene: data }
|
||||||
|
/* make a list of genes for which we do not have data */
|
||||||
|
const genesToFetch = _.filter(genes, g => expressionData[g] === undefined);
|
||||||
|
|
||||||
|
dispatch({ type: "expression load start" });
|
||||||
|
|
||||||
|
/* Fetch data for any genes not in cache */
|
||||||
|
if (genesToFetch.length) {
|
||||||
|
try {
|
||||||
|
const newExpressionData = await fetchData(genesToFetch);
|
||||||
|
expressionData = {
|
||||||
|
...expressionData,
|
||||||
|
...newExpressionData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({ type: "expression load error", error });
|
||||||
|
throw error; // rethrow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ type: "expression load success", expressionData });
|
||||||
|
return expressionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
dispatch({ type: "get single gene expression for coloring started" });
|
||||||
|
try {
|
||||||
|
await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||||
|
const { world } = getState();
|
||||||
|
dispatch({
|
||||||
|
type: "color by expression",
|
||||||
|
gene,
|
||||||
|
data: {
|
||||||
|
[gene]: world.varData.col(gene).asArray()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: "get single gene expression for coloring error",
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUserDefinedGene = gene => async (dispatch, getState) => {
|
||||||
|
dispatch({ type: "request user defined gene started" });
|
||||||
|
try {
|
||||||
|
await await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||||
|
const { world } = getState();
|
||||||
|
|
||||||
|
/* then send the success case action through */
|
||||||
|
return dispatch({
|
||||||
|
type: "request user defined gene success",
|
||||||
|
data: {
|
||||||
|
genes: [gene],
|
||||||
|
expression: world.varData.col(gene).asArray()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return dispatch({
|
||||||
|
type: "request user defined gene error",
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchDiffExpErrors = (dispatch, response) => {
|
||||||
|
switch (response.status) {
|
||||||
|
case 403:
|
||||||
|
dispatchNetworkErrorMessageToUser(
|
||||||
|
"Too many cells selected for differential experesion calculation - please make a smaller selection."
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 501:
|
||||||
|
dispatchNetworkErrorMessageToUser(
|
||||||
|
"Differential expression is not implemented."
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const msg = `Unexpected differential expression HTTP response ${
|
||||||
|
response.status
|
||||||
|
}, ${response.statusText}`;
|
||||||
|
dispatchNetworkErrorMessageToUser(msg);
|
||||||
|
dispatch({
|
||||||
|
type: "request differential expression error",
|
||||||
|
error: new Error(msg)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||||
|
dispatch,
|
||||||
|
getState
|
||||||
|
) => {
|
||||||
|
dispatch({ type: "request differential expression started" });
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
Steps:
|
||||||
|
1. get the most differentially expressed genes
|
||||||
|
2. get expression data for each
|
||||||
|
*/
|
||||||
|
const state = getState();
|
||||||
|
const { universe } = state;
|
||||||
|
|
||||||
|
// Legal values are null, Array or TypedArray. Null is initial state.
|
||||||
|
if (!set1) set1 = [];
|
||||||
|
if (!set2) set2 = [];
|
||||||
|
|
||||||
|
// These lines ensure that we convert any TypedArray to an Array.
|
||||||
|
// This is necessary because JSON.stringify() does some very strange
|
||||||
|
// things with TypedArrays (they are marshalled to JSON objects, rather
|
||||||
|
// than being marshalled as a JSON array).
|
||||||
|
set1 = Array.isArray(set1) ? set1 : Array.from(set1);
|
||||||
|
set2 = Array.isArray(set2) ? set2 : Array.from(set2);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${globals.API.prefix}${globals.API.version}diffexp/obs`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: "topN",
|
||||||
|
count: num_genes,
|
||||||
|
set1: { filter: { obs: { index: set1 } } },
|
||||||
|
set2: { filter: { obs: { index: set2 } } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
|
||||||
|
return dispatchDiffExpErrors(dispatch, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
// result is [ [varIdx, ...], ... ]
|
||||||
|
const topNGenes = _.map(data, r =>
|
||||||
|
universe.varAnnotations.at(r[0], "name")
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Kick off secondary action to fetch all of the expression data for the
|
||||||
|
topN expressed genes.
|
||||||
|
*/
|
||||||
|
await _doRequestExpressionData(dispatch, getState, topNGenes);
|
||||||
|
|
||||||
|
/* then send the success case action through */
|
||||||
|
return dispatch({
|
||||||
|
type: "request differential expression success",
|
||||||
|
data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return dispatch({
|
||||||
|
type: "request differential expression error",
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetInterface = () => (dispatch, getState) => {
|
||||||
|
const { universe } = getState();
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "user reset start"
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "clear all user defined genes"
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "clear differential expression"
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "reset colorscale"
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "clear scatterplot"
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "reset World to eq Universe",
|
||||||
|
universe
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "increment graph render counter"
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "user reset end"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
regraph,
|
||||||
|
resetInterface,
|
||||||
|
requestSingleGeneExpressionCountsForColoringPOST,
|
||||||
|
requestDifferentialExpression,
|
||||||
|
requestUserDefinedGene,
|
||||||
|
doInitialDataLoad
|
||||||
|
};
|
||||||
@@ -1,47 +1,37 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import _ from "lodash";
|
|
||||||
import Helmet from "react-helmet";
|
import Helmet from "react-helmet";
|
||||||
import Container from "./framework/container";
|
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import PulseLoader from "halogen/PulseLoader";
|
|
||||||
|
|
||||||
|
import Container from "./framework/container";
|
||||||
import LeftSideBar from "./leftsidebar";
|
import LeftSideBar from "./leftsidebar";
|
||||||
import Parallel from "./continuous/parallel";
|
import Legend from "./continuousLegend";
|
||||||
import Joy from "./joy/joy";
|
|
||||||
import Graph from "./graph/graph";
|
import Graph from "./graph/graph";
|
||||||
import * as globals from "../globals";
|
|
||||||
import actions from "../actions";
|
import actions from "../actions";
|
||||||
|
|
||||||
import SectionHeader from "./framework/sectionHeader";
|
@connect(state => ({
|
||||||
|
loading: state.controls.loading,
|
||||||
@connect(state => {
|
error: state.controls.error,
|
||||||
return {
|
graphRenderCounter: state.controls.graphRenderCounter
|
||||||
cells: state.cells
|
}))
|
||||||
};
|
|
||||||
})
|
|
||||||
class App extends React.Component {
|
class App extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {};
|
this.state = {};
|
||||||
}
|
}
|
||||||
_onURLChanged() {
|
|
||||||
this.props.dispatch({ type: "url changed", url: document.location.href });
|
|
||||||
}
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
/* listen for url changes, fire one when we start the app up */
|
/* listen for url changes, fire one when we start the app up */
|
||||||
window.addEventListener("popstate", this._onURLChanged);
|
window.addEventListener("popstate", this._onURLChanged);
|
||||||
this._onURLChanged();
|
this._onURLChanged();
|
||||||
|
|
||||||
this.props.dispatch(actions.initialize());
|
dispatch(actions.doInitialDataLoad(window.location.search));
|
||||||
|
|
||||||
/*
|
|
||||||
first request includes query straight off the url bar for now
|
|
||||||
*/
|
|
||||||
this.props.dispatch(actions.requestCells(window.location.search));
|
|
||||||
/* listen for resize events */
|
/* listen for resize events */
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
this.props.dispatch({
|
dispatch({
|
||||||
type: "window resize",
|
type: "window resize",
|
||||||
data: {
|
data: {
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
@@ -49,7 +39,7 @@ class App extends React.Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.props.dispatch({
|
dispatch({
|
||||||
type: "window resize",
|
type: "window resize",
|
||||||
data: {
|
data: {
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
@@ -58,32 +48,31 @@ class App extends React.Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
_onURLChanged() {
|
||||||
// console.log('app:', this.props, this.state)
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
|
dispatch({ type: "url changed", url: document.location.href });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { loading, error, graphRenderCounter } = this.props;
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Helmet title="cellxgene" />
|
<Helmet title="cellxgene" />
|
||||||
{this.props.cells.loading ? (
|
{loading ? (
|
||||||
<div
|
<div
|
||||||
style={{ position: "fixed", left: window.innerWidth / 2, top: 150 }}
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
fontWeight: 500,
|
||||||
|
top: window.innerHeight / 2,
|
||||||
|
left: window.innerWidth / 2 - 50
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PulseLoader color="rgb(0,0,0)" size="10px" margin="4px" />
|
loading cellxgene
|
||||||
<span
|
|
||||||
style={{ fontFamily: globals.accentFont, fontStyle: "italic" }}
|
|
||||||
>
|
|
||||||
loading cells
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{this.props.cells.error ? "Error loading cells" : null}
|
|
||||||
{false ? (
|
|
||||||
<Joy data={this.state.expressions && this.state.expressions.data} />
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<LeftSideBar />
|
{loading ? null : <LeftSideBar />}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: 15,
|
padding: 15,
|
||||||
@@ -91,8 +80,8 @@ class App extends React.Component {
|
|||||||
marginLeft: 350 /* but responsive */
|
marginLeft: 350 /* but responsive */
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Graph />
|
{loading ? null : <Graph key={graphRenderCounter} />}
|
||||||
{/*<Parallel/>*/}
|
<Legend />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
/*
|
||||||
|
https://bl.ocks.org/mbostock/4341954
|
||||||
|
https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
|
||||||
|
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
|
||||||
|
*/
|
||||||
|
// jshint esversion: 6
|
||||||
|
import React from "react";
|
||||||
|
import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
import memoize from "memoize-one";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
import actions from "../../actions";
|
||||||
|
import { makeContinuousDimensionName } from "../../util/nameCreators";
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
world: state.world,
|
||||||
|
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||||
|
continuousSelection: state.continuousSelection,
|
||||||
|
differential: state.differential,
|
||||||
|
colorAccessor: state.colors.colorAccessor
|
||||||
|
}))
|
||||||
|
class HistogramBrush extends React.Component {
|
||||||
|
static getColumn(world, field, clipped = true) {
|
||||||
|
/*
|
||||||
|
Return the underlying Dataframe column for our field. By default,
|
||||||
|
returns the clipped column. If clipped===false, will return the
|
||||||
|
unclipped column.
|
||||||
|
*/
|
||||||
|
const obsAnnotations = clipped
|
||||||
|
? world.obsAnnotations
|
||||||
|
: world.unclipped.obsAnnotations;
|
||||||
|
const varData = clipped ? world.varData : world.unclipped.varData;
|
||||||
|
if (obsAnnotations.hasCol(field)) {
|
||||||
|
return obsAnnotations.col(field);
|
||||||
|
}
|
||||||
|
return varData.col(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
calcHistogramCache = memoize((col, field) => {
|
||||||
|
/*
|
||||||
|
recalculate expensive stuff, notably bins, summaries, etc.
|
||||||
|
*/
|
||||||
|
const histogramCache = {};
|
||||||
|
const values = col.asArray();
|
||||||
|
const summary = col.summarize();
|
||||||
|
const { min: domainMin, max: domainMax } = summary;
|
||||||
|
histogramCache.x = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain([domainMin, domainMax])
|
||||||
|
.range([0, this.width - this.marginRight]);
|
||||||
|
|
||||||
|
histogramCache.bins = d3
|
||||||
|
.histogram()
|
||||||
|
.domain(histogramCache.x.domain())
|
||||||
|
.thresholds(40)(values);
|
||||||
|
|
||||||
|
const yMax = histogramCache.bins
|
||||||
|
.map(b => b.length)
|
||||||
|
.reduce((a, b) => Math.max(a, b));
|
||||||
|
histogramCache.y = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain([0, yMax])
|
||||||
|
.range([this.height - this.marginBottom, 0]);
|
||||||
|
|
||||||
|
return histogramCache;
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.width = 340;
|
||||||
|
this.height = 100;
|
||||||
|
this.marginBottom = 20; // space for X axis & labels
|
||||||
|
this.marginRight = 40; // space for Y axis & labels
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { field } = this.props;
|
||||||
|
const { x, y, bins, svgRef } = this._histogram;
|
||||||
|
|
||||||
|
this.renderAxesBrushBins(x, y, bins, svgRef, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const { field, world, continuousSelection } = this.props;
|
||||||
|
const { x, y, bins, svgRef } = this._histogram;
|
||||||
|
let { brushXselection, brushX } = this.state;
|
||||||
|
let forceBrushUpdate = false;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Update our axis if the underlying dataframe column has changed
|
||||||
|
*/
|
||||||
|
const dfColumn = HistogramBrush.getColumn(world, field);
|
||||||
|
const oldDfColumn = HistogramBrush.getColumn(
|
||||||
|
prevProps.world,
|
||||||
|
prevProps.field
|
||||||
|
);
|
||||||
|
if (dfColumn !== oldDfColumn) {
|
||||||
|
({ brushXselection, brushX } = this.renderAxesBrushBins(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
bins,
|
||||||
|
svgRef,
|
||||||
|
field
|
||||||
|
));
|
||||||
|
forceBrushUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
if the selection has changed, ensure that the brush correctly reflects
|
||||||
|
the underlying selection.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
forceBrushUpdate ||
|
||||||
|
continuousSelection !== prevProps.continuousSelection
|
||||||
|
) {
|
||||||
|
const { isObs, isUserDefined, isDiffExp } = this.props;
|
||||||
|
const myName = makeContinuousDimensionName(
|
||||||
|
{ isObs, isUserDefined, isDiffExp },
|
||||||
|
field
|
||||||
|
);
|
||||||
|
const range = continuousSelection[myName];
|
||||||
|
if (brushXselection) {
|
||||||
|
const selection = d3.brushSelection(brushXselection.node());
|
||||||
|
if (!range && selection) {
|
||||||
|
/* no active selection - clear brush */
|
||||||
|
brushXselection.call(brushX.move, null);
|
||||||
|
} else if (range && !selection) {
|
||||||
|
/* there is an active selection, but no brush - set the brush */
|
||||||
|
const x0 = x(range[0]);
|
||||||
|
const x1 = x(range[1]);
|
||||||
|
brushXselection.call(brushX.move, [x0, x1]);
|
||||||
|
} else if (range && selection) {
|
||||||
|
/* there is an active selection and a brush - make sure they match */
|
||||||
|
const moveDeltaThreshold = 1;
|
||||||
|
const x0 = x(range[0]);
|
||||||
|
const x1 = x(range[1]);
|
||||||
|
const dX0 = Math.abs(x0 - selection[0]);
|
||||||
|
const dX1 = Math.abs(x1 - selection[1]);
|
||||||
|
/*
|
||||||
|
only update the brush if it is grossly incorrect,
|
||||||
|
as defined by the moveDeltaThreshold
|
||||||
|
*/
|
||||||
|
if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) {
|
||||||
|
brushXselection.call(brushX.move, [x0, x1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBrush(selection, x, eventType) {
|
||||||
|
const type = `continuous metadata histogram ${eventType}`;
|
||||||
|
return () => {
|
||||||
|
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
|
||||||
|
|
||||||
|
// ignore programmatically generated events
|
||||||
|
if (!d3.event.sourceEvent) return;
|
||||||
|
// ignore cascading events, which are programmatically generated
|
||||||
|
if (d3.event.sourceEvent.sourceEvent) return;
|
||||||
|
|
||||||
|
if (d3.event.selection) {
|
||||||
|
dispatch({
|
||||||
|
type,
|
||||||
|
selection: field,
|
||||||
|
continuousNamespace: {
|
||||||
|
isObs,
|
||||||
|
isUserDefined,
|
||||||
|
isDiffExp
|
||||||
|
},
|
||||||
|
range: [x(d3.event.selection[0]), x(d3.event.selection[1])]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch({
|
||||||
|
type,
|
||||||
|
selection: field,
|
||||||
|
continuousNamespace: {
|
||||||
|
isObs,
|
||||||
|
isUserDefined,
|
||||||
|
isDiffExp
|
||||||
|
},
|
||||||
|
range: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onBrushEnd(selection, x) {
|
||||||
|
return () => {
|
||||||
|
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
|
||||||
|
const minAllowedBrushSize = 10;
|
||||||
|
const smallAmountToAvoidInfiniteLoop = 0.1;
|
||||||
|
|
||||||
|
// ignore programmatically generated events
|
||||||
|
if (!d3.event.sourceEvent) return;
|
||||||
|
// ignore cascading events, which are programmatically generated
|
||||||
|
if (d3.event.sourceEvent.sourceEvent) return;
|
||||||
|
|
||||||
|
if (d3.event.selection) {
|
||||||
|
let _range;
|
||||||
|
|
||||||
|
if (
|
||||||
|
d3.event.selection[1] - d3.event.selection[0] >
|
||||||
|
minAllowedBrushSize
|
||||||
|
) {
|
||||||
|
_range = [x(d3.event.selection[0]), x(d3.event.selection[1])];
|
||||||
|
} else {
|
||||||
|
/* the user selected range is too small and will be hidden #587, so take control of it procedurally */
|
||||||
|
/* https://stackoverflow.com/questions/12354729/d3-js-limit-size-of-brush */
|
||||||
|
|
||||||
|
const procedurallyResizedBrushWidth =
|
||||||
|
d3.event.selection[0] +
|
||||||
|
minAllowedBrushSize +
|
||||||
|
smallAmountToAvoidInfiniteLoop; //
|
||||||
|
|
||||||
|
_range = [x(d3.event.selection[0]), x(procedurallyResizedBrushWidth)];
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "continuous metadata histogram end",
|
||||||
|
selection: field,
|
||||||
|
continuousNamespace: {
|
||||||
|
isObs,
|
||||||
|
isUserDefined,
|
||||||
|
isDiffExp
|
||||||
|
},
|
||||||
|
range: _range
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch({
|
||||||
|
type: "continuous metadata histogram cancel",
|
||||||
|
selection: field,
|
||||||
|
continuousNamespace: {
|
||||||
|
isObs,
|
||||||
|
isUserDefined,
|
||||||
|
isDiffExp
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHistogram(svgRef) {
|
||||||
|
const { field, world } = this.props;
|
||||||
|
const col = HistogramBrush.getColumn(world, field);
|
||||||
|
const histogramCache = this.calcHistogramCache(col, field);
|
||||||
|
const { x, y, bins } = histogramCache;
|
||||||
|
this._histogram = { x, y, bins, svgRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
handleColorAction() {
|
||||||
|
const { dispatch, field, world, ranges } = this.props;
|
||||||
|
|
||||||
|
if (world.obsAnnotations.hasCol(field)) {
|
||||||
|
dispatch({
|
||||||
|
type: "color by continuous metadata",
|
||||||
|
colorAccessor: field,
|
||||||
|
rangeForColorAccessor: ranges
|
||||||
|
});
|
||||||
|
} else if (world.varData.hasCol(field)) {
|
||||||
|
dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeHistogram() {
|
||||||
|
const {
|
||||||
|
dispatch,
|
||||||
|
field,
|
||||||
|
colorAccessor,
|
||||||
|
scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor
|
||||||
|
} = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "clear user defined gene",
|
||||||
|
data: field
|
||||||
|
});
|
||||||
|
if (field === colorAccessor) {
|
||||||
|
dispatch({
|
||||||
|
type: "reset colorscale"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (field === scatterplotXXaccessor) {
|
||||||
|
dispatch({
|
||||||
|
type: "set scatterplot x",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (field === scatterplotYYaccessor) {
|
||||||
|
dispatch({
|
||||||
|
type: "set scatterplot y",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetGeneAsScatterplotX() {
|
||||||
|
return () => {
|
||||||
|
const { dispatch, field } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "set scatterplot x",
|
||||||
|
data: field
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetGeneAsScatterplotY() {
|
||||||
|
return () => {
|
||||||
|
const { dispatch, field } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "set scatterplot y",
|
||||||
|
data: field
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAxesBrushBins(x, y, bins, svgRef, field) {
|
||||||
|
const svg = d3.select(svgRef);
|
||||||
|
|
||||||
|
/* Remove everything */
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
/* BINS */
|
||||||
|
svg
|
||||||
|
.insert("g", "*")
|
||||||
|
.attr("fill", "#bbb")
|
||||||
|
.selectAll("rect")
|
||||||
|
.data(bins)
|
||||||
|
.enter()
|
||||||
|
.append("rect")
|
||||||
|
.attr("x", d => x(d.x0) + 1)
|
||||||
|
.attr("y", d => y(d.length))
|
||||||
|
.attr("width", d => Math.abs(x(d.x1) - x(d.x0) - 1))
|
||||||
|
.attr("height", d => y(0) - y(d.length));
|
||||||
|
|
||||||
|
/* BRUSH */
|
||||||
|
const brushX = d3
|
||||||
|
.brushX()
|
||||||
|
.extent([[0, 0], [this.width - this.marginRight, this.height]])
|
||||||
|
/*
|
||||||
|
emit start so that the Undoable history can save an undo point
|
||||||
|
upon drag start, and ignore the subsequent intermediate drag events.
|
||||||
|
*/
|
||||||
|
.on("start", this.onBrush(field, x.invert, "start").bind(this))
|
||||||
|
.on("brush", this.onBrush(field, x.invert, "brush").bind(this))
|
||||||
|
.on("end", this.onBrushEnd(field, x.invert).bind(this));
|
||||||
|
const brushXselection = d3
|
||||||
|
.select(svgRef)
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "brush")
|
||||||
|
.attr("data-testid", `${svgRef.dataset.testid}-brush`)
|
||||||
|
.call(brushX);
|
||||||
|
|
||||||
|
/* X AXIS */
|
||||||
|
svg
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "axis axis--x")
|
||||||
|
.attr("transform", `translate(0,${this.height - this.marginBottom})`)
|
||||||
|
.call(d3.axisBottom(x).ticks(5));
|
||||||
|
|
||||||
|
/* Y AXIS */
|
||||||
|
svg
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "axis axis--y")
|
||||||
|
.attr("transform", `translate(${this.width - this.marginRight},0)`)
|
||||||
|
.call(d3.axisRight(y).ticks(3));
|
||||||
|
|
||||||
|
/* axis style */
|
||||||
|
svg.selectAll(".axis text").style("fill", "rgb(80,80,80)");
|
||||||
|
svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)");
|
||||||
|
svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)");
|
||||||
|
|
||||||
|
const newState = { brushX, brushXselection };
|
||||||
|
this.setState(newState);
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
world,
|
||||||
|
colorAccessor,
|
||||||
|
isUserDefined,
|
||||||
|
isDiffExp,
|
||||||
|
logFoldChange,
|
||||||
|
pvalAdj,
|
||||||
|
scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor,
|
||||||
|
zebra
|
||||||
|
} = this.props;
|
||||||
|
const fieldForId = field.replace(/\s/g, "_");
|
||||||
|
const {
|
||||||
|
min: unclippedRangeMin,
|
||||||
|
max: unclippedRangeMax
|
||||||
|
} = HistogramBrush.getColumn(world, field, false).summarize();
|
||||||
|
const unclippedRangeMinColor =
|
||||||
|
world.clipQuantiles.min === 0 ? "#bbb" : globals.blue;
|
||||||
|
const unclippedRangeMaxColor =
|
||||||
|
world.clipQuantiles.max === 1 ? "#bbb" : globals.blue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`histogram_${fieldForId}`}
|
||||||
|
data-testid={`histogram-${field}`}
|
||||||
|
data-testclass={
|
||||||
|
isDiffExp
|
||||||
|
? "histogram-diffexp"
|
||||||
|
: isUserDefined
|
||||||
|
? "histogram-user-gene"
|
||||||
|
: "histogram-continuous-metadata"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: globals.leftSidebarSectionPadding,
|
||||||
|
backgroundColor: zebra ? globals.lightestGrey : "white"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingBottom: "8px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDiffExp || isUserDefined ? (
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style={{ marginRight: 7 }}
|
||||||
|
className="bp3-icon-standard bp3-icon-scatter-plot"
|
||||||
|
/>
|
||||||
|
<ButtonGroup style={{ marginRight: 7 }}>
|
||||||
|
<Button
|
||||||
|
data-testid={`plot-x-${field}`}
|
||||||
|
onClick={this.handleSetGeneAsScatterplotX(field).bind(this)}
|
||||||
|
active={scatterplotXXaccessor === field}
|
||||||
|
intent={scatterplotXXaccessor === field ? "primary" : "none"}
|
||||||
|
>
|
||||||
|
plot x
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid={`plot-y-${field}`}
|
||||||
|
onClick={this.handleSetGeneAsScatterplotY(field).bind(this)}
|
||||||
|
active={scatterplotYYaccessor === field}
|
||||||
|
intent={scatterplotYYaccessor === field ? "primary" : "none"}
|
||||||
|
>
|
||||||
|
plot y
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isUserDefined ? (
|
||||||
|
<Button
|
||||||
|
minimal
|
||||||
|
onClick={this.removeHistogram.bind(this)}
|
||||||
|
style={{
|
||||||
|
color: globals.blue,
|
||||||
|
cursor: "pointer",
|
||||||
|
marginLeft: 7
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Tooltip content="Use as color scale" position="bottom">
|
||||||
|
<Button
|
||||||
|
onClick={this.handleColorAction.bind(this)}
|
||||||
|
active={colorAccessor === field}
|
||||||
|
intent={colorAccessor === field ? "primary" : "none"}
|
||||||
|
data-testclass="colorby"
|
||||||
|
data-testid={`colorby-${field}`}
|
||||||
|
icon="tint"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
width={this.width}
|
||||||
|
height={this.height}
|
||||||
|
id={`histogram_${fieldForId}_svg`}
|
||||||
|
data-testclass="histogram-plot"
|
||||||
|
data-testid={`histogram-${field}-plot`}
|
||||||
|
ref={svgRef => {
|
||||||
|
this.drawHistogram(svgRef);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: unclippedRangeMinColor }}>
|
||||||
|
min {unclippedRangeMin.toPrecision(4)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testclass="brushable-histogram-field-name"
|
||||||
|
style={{ fontStyle: "italic" }}
|
||||||
|
>
|
||||||
|
{field}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: unclippedRangeMaxColor }}>
|
||||||
|
max {unclippedRangeMax.toPrecision(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDiffExp ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "baseline"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>log fold change:</strong>
|
||||||
|
{` ${logFoldChange.toPrecision(4)}`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 7,
|
||||||
|
padding: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>p-value (adj):</strong>
|
||||||
|
{pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HistogramBrush;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import React from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
import Category from "./category";
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
categoricalSelection: state.categoricalSelection
|
||||||
|
}))
|
||||||
|
class Categories extends React.Component {
|
||||||
|
render() {
|
||||||
|
const { categoricalSelection } = this.props;
|
||||||
|
if (!categoricalSelection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: globals.leftSidebarSectionPadding
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={Object.assign({}, globals.leftSidebarSectionHeading, {
|
||||||
|
marginTop: 4
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Categorical Metadata
|
||||||
|
</p>
|
||||||
|
{_.map(categoricalSelection, (catState, catName) => (
|
||||||
|
<Category key={catName} metadataField={catName} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Categories;
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||||
|
import { Button, Tooltip } from "@blueprintjs/core";
|
||||||
|
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
import Value from "./value";
|
||||||
|
import sortedCategoryValues from "./util";
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
colorAccessor: state.colors.colorAccessor,
|
||||||
|
categoricalSelection: state.categoricalSelection
|
||||||
|
}))
|
||||||
|
class Category extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
isChecked: true,
|
||||||
|
isExpanded: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const { categoricalSelection, metadataField } = this.props;
|
||||||
|
if (categoricalSelection !== prevProps.categoricalSelection) {
|
||||||
|
const cat = categoricalSelection[metadataField];
|
||||||
|
const categoryCount = {
|
||||||
|
// total number of categories in this dimension
|
||||||
|
totalCatCount: cat.numCategories,
|
||||||
|
// number of selected options in this category
|
||||||
|
selectedCatCount: _.reduce(
|
||||||
|
cat.categorySelected,
|
||||||
|
(res, cond) => (cond ? res + 1 : res),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if (categoryCount.selectedCatCount === categoryCount.totalCatCount) {
|
||||||
|
/* everything is on, so not indeterminate */
|
||||||
|
this.checkbox.indeterminate = false;
|
||||||
|
this.setState({ isChecked: true }); // eslint-disable-line react/no-did-update-set-state
|
||||||
|
} else if (categoryCount.selectedCatCount === 0) {
|
||||||
|
/* nothing is on, so no */
|
||||||
|
this.checkbox.indeterminate = false;
|
||||||
|
this.setState({ isChecked: false }); // eslint-disable-line react/no-did-update-set-state
|
||||||
|
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) {
|
||||||
|
/* to be explicit... */
|
||||||
|
this.checkbox.indeterminate = true;
|
||||||
|
this.setState({ isChecked: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleColorChange = () => {
|
||||||
|
const { dispatch, metadataField } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "color by categorical metadata",
|
||||||
|
colorAccessor: metadataField
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleAll() {
|
||||||
|
const { dispatch, metadataField } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "categorical metadata filter all of these",
|
||||||
|
metadataField
|
||||||
|
});
|
||||||
|
this.setState({ isChecked: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNone() {
|
||||||
|
const { dispatch, metadataField } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "categorical metadata filter none of these",
|
||||||
|
metadataField
|
||||||
|
});
|
||||||
|
this.setState({ isChecked: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleToggleAllClick() {
|
||||||
|
const { isChecked } = this.state;
|
||||||
|
// || this.checkbox.indeterminate === false
|
||||||
|
if (isChecked) {
|
||||||
|
this.toggleNone();
|
||||||
|
} else if (!isChecked) {
|
||||||
|
this.toggleAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCategoryItems() {
|
||||||
|
const { categoricalSelection, metadataField } = this.props;
|
||||||
|
|
||||||
|
const cat = categoricalSelection[metadataField];
|
||||||
|
const optTuples = sortedCategoryValues([...cat.categoryIndices]);
|
||||||
|
return _.map(optTuples, (tuple, i) => (
|
||||||
|
<Value
|
||||||
|
optTuples={optTuples}
|
||||||
|
key={tuple[1]}
|
||||||
|
metadataField={metadataField}
|
||||||
|
categoryIndex={tuple[1]}
|
||||||
|
i={i}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { isExpanded, isChecked } = this.state;
|
||||||
|
const { metadataField, colorAccessor, categoricalSelection } = this.props;
|
||||||
|
const { isTruncated } = categoricalSelection[metadataField];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: globals.maxControlsWidth
|
||||||
|
}}
|
||||||
|
data-testclass="category"
|
||||||
|
data-testid={`category-${metadataField}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "baseline"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="bp3-control bp3-checkbox">
|
||||||
|
<input
|
||||||
|
data-testclass="category-select"
|
||||||
|
data-testid={`category-select-${metadataField}`}
|
||||||
|
onChange={this.handleToggleAllClick.bind(this)}
|
||||||
|
ref={el => {
|
||||||
|
this.checkbox = el;
|
||||||
|
return el;
|
||||||
|
}}
|
||||||
|
checked={isChecked}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="bp3-control-indicator" />
|
||||||
|
{""}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-testid={`category-expand-${metadataField}`}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-block"
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ isExpanded: !isExpanded });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metadataField}
|
||||||
|
{isExpanded ? (
|
||||||
|
<FaChevronDown
|
||||||
|
data-testclass="category-expand-is-expanded"
|
||||||
|
style={{ fontSize: 10, marginLeft: 5 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaChevronRight
|
||||||
|
data-testclass="category-expand-is-not-expanded"
|
||||||
|
style={{ fontSize: 10, marginLeft: 5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip content="Use as color scale" position="bottom">
|
||||||
|
<Button
|
||||||
|
data-testclass="colorby"
|
||||||
|
data-testid={`colorby-${metadataField}`}
|
||||||
|
onClick={this.handleColorChange}
|
||||||
|
active={colorAccessor === metadataField}
|
||||||
|
intent={colorAccessor === metadataField ? "primary" : "none"}
|
||||||
|
icon="tint"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: 26 }}>
|
||||||
|
{isExpanded ? this.renderCategoryItems() : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isExpanded && isTruncated ? (
|
||||||
|
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Category;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import React from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
|
||||||
|
@connect()
|
||||||
|
class Occupancy extends React.Component {
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
occupancy,
|
||||||
|
colorScale,
|
||||||
|
categoricalSelection,
|
||||||
|
colorAccessor,
|
||||||
|
schema
|
||||||
|
} = this.props;
|
||||||
|
const width = 100;
|
||||||
|
const height = 11;
|
||||||
|
|
||||||
|
const categories = _.filter(schema.annotations.obs, {
|
||||||
|
name: colorAccessor
|
||||||
|
})[0].categories;
|
||||||
|
|
||||||
|
const x = d3
|
||||||
|
.scaleLinear()
|
||||||
|
/* get all the keys d[1] as an array, then find the sum */
|
||||||
|
.domain([0, d3.sum(Array.from(occupancy, d => d[1]))])
|
||||||
|
.range([0, width]);
|
||||||
|
|
||||||
|
let currentOffset = 0;
|
||||||
|
|
||||||
|
const stacks = categoricalSelection[colorAccessor].categoryValues.map(d => {
|
||||||
|
const o = occupancy.get(d);
|
||||||
|
|
||||||
|
const scaledValue = x(o);
|
||||||
|
|
||||||
|
const stackItem = {
|
||||||
|
key: d,
|
||||||
|
value: o || 0,
|
||||||
|
rectWidth: o ? scaledValue : 0,
|
||||||
|
offset: currentOffset,
|
||||||
|
fill: o ? colorScale(categories.indexOf(d)) : "rgb(255,255,255)"
|
||||||
|
};
|
||||||
|
currentOffset += o ? scaledValue : 0;
|
||||||
|
return stackItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
marginRight: 5,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stacks.map(d => (
|
||||||
|
<rect
|
||||||
|
key={d.key}
|
||||||
|
width={d.rectWidth}
|
||||||
|
height={height}
|
||||||
|
x={d.offset}
|
||||||
|
title={d.metadataField}
|
||||||
|
fill={d.fill}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Occupancy;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
|
||||||
|
// values is [ [optVal, optIdx], ...]
|
||||||
|
// index is range array
|
||||||
|
// return sorted index
|
||||||
|
|
||||||
|
import isNumber from "is-number";
|
||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
const sortedCategoryValues = values => {
|
||||||
|
/* this sort could be memoized for perf */
|
||||||
|
|
||||||
|
const strings = [];
|
||||||
|
const ints = [];
|
||||||
|
|
||||||
|
_.forEach(values, v => {
|
||||||
|
if (isNumber(v[0])) {
|
||||||
|
ints.push(v);
|
||||||
|
} else {
|
||||||
|
strings.push(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
strings.sort((a, b) => {
|
||||||
|
const textA = String(a[0]).toUpperCase();
|
||||||
|
const textB = String(b[0]).toUpperCase();
|
||||||
|
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
ints.sort((a, b) => +a[0] - +b[0]);
|
||||||
|
|
||||||
|
return ints.concat(strings);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sortedCategoryValues;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import React from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import Occupancy from "./occupancy";
|
||||||
|
import { countCategoryValues2D } from "../../util/stateManager/worldUtil";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
categoricalSelection: state.categoricalSelection,
|
||||||
|
colorScale: state.colors.scale,
|
||||||
|
colorAccessor: state.colors.colorAccessor,
|
||||||
|
schema: _.get(state.world, "schema", null),
|
||||||
|
world: state.world
|
||||||
|
}))
|
||||||
|
class CategoryValue extends React.Component {
|
||||||
|
toggleOff() {
|
||||||
|
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "categorical metadata filter deselect",
|
||||||
|
metadataField,
|
||||||
|
categoryIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleOn() {
|
||||||
|
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "categorical metadata filter select",
|
||||||
|
metadataField,
|
||||||
|
categoryIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
categoricalSelection,
|
||||||
|
metadataField,
|
||||||
|
categoryIndex,
|
||||||
|
colorAccessor,
|
||||||
|
colorScale,
|
||||||
|
i,
|
||||||
|
schema,
|
||||||
|
world
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (!categoricalSelection) return null;
|
||||||
|
|
||||||
|
const category = categoricalSelection[metadataField];
|
||||||
|
const selected = category.categorySelected[categoryIndex];
|
||||||
|
const count = category.categoryCounts[categoryIndex];
|
||||||
|
const value = category.categoryValues[categoryIndex];
|
||||||
|
const displayString = String(
|
||||||
|
category.categoryValues[categoryIndex]
|
||||||
|
).valueOf();
|
||||||
|
|
||||||
|
/* this is the color scale, so add swatches below */
|
||||||
|
const isColorBy = metadataField === colorAccessor;
|
||||||
|
let categories = null;
|
||||||
|
let occupancy = null;
|
||||||
|
|
||||||
|
if (isColorBy && schema) {
|
||||||
|
categories = _.filter(schema.annotations.obs, {
|
||||||
|
name: colorAccessor
|
||||||
|
})[0].categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colorAccessor && !isColorBy && categoricalSelection[colorAccessor]) {
|
||||||
|
occupancy = countCategoryValues2D(
|
||||||
|
metadataField,
|
||||||
|
colorAccessor,
|
||||||
|
world.obsAnnotations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between"
|
||||||
|
}}
|
||||||
|
data-testclass="categorical-row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
userSelect: "none",
|
||||||
|
width: globals.leftSidebarWidth - 130,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="bp3-control bp3-checkbox">
|
||||||
|
<input
|
||||||
|
onChange={
|
||||||
|
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)
|
||||||
|
}
|
||||||
|
data-testclass="categorical-value-select"
|
||||||
|
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
|
||||||
|
checked={selected}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="bp3-control-indicator" />
|
||||||
|
<span
|
||||||
|
data-testid={`categorical-value-${metadataField}-${displayString}`}
|
||||||
|
data-testclass="categorical-value"
|
||||||
|
>
|
||||||
|
{displayString}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span style={{ flexShrink: 0 }}>
|
||||||
|
{colorAccessor &&
|
||||||
|
!isColorBy &&
|
||||||
|
categoricalSelection[colorAccessor] ? (
|
||||||
|
<Occupancy
|
||||||
|
occupancy={occupancy.get(
|
||||||
|
category.categoryValues[categoryIndex]
|
||||||
|
)}
|
||||||
|
{...this.props}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
data-testclass="categorical-value-count"
|
||||||
|
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
marginLeft: 5,
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
backgroundColor:
|
||||||
|
isColorBy && categories
|
||||||
|
? colorScale(categories.indexOf(value))
|
||||||
|
: "inherit"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryValue;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
|
import HistogramBrush from "../brushableHistogram";
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
obsAnnotations: _.get(state.world, "obsAnnotations", null),
|
||||||
|
colorAccessor: state.colors.colorAccessor,
|
||||||
|
colorScale: state.colors.scale,
|
||||||
|
schema: _.get(state.world, "schema", null)
|
||||||
|
}))
|
||||||
|
class Continuous extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.hasContinuous = false;
|
||||||
|
this.continuousChecked = false;
|
||||||
|
|
||||||
|
this.state = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {}
|
||||||
|
|
||||||
|
handleColorAction(key) {
|
||||||
|
return () => {
|
||||||
|
const { dispatch, obsAnnotations } = this.props;
|
||||||
|
const summary = obsAnnotations.col(key).summarize();
|
||||||
|
dispatch({
|
||||||
|
type: "color by continuous metadata",
|
||||||
|
colorAccessor: key,
|
||||||
|
rangeForColorAccessor: summary
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { obsAnnotations, schema } = this.props;
|
||||||
|
if (schema && !this.continuousChecked) {
|
||||||
|
this.hasContinuous = _.some(
|
||||||
|
schema.annotations.obs,
|
||||||
|
d => d.type === "int32" || d.type === "float32"
|
||||||
|
);
|
||||||
|
this.continuousChecked = true; /* only do this once */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* initial value for iterator to simulate index, ranges is an object */
|
||||||
|
let zebra = -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{this.hasContinuous ? (
|
||||||
|
<p
|
||||||
|
style={Object.assign({}, globals.leftSidebarSectionHeading, {
|
||||||
|
marginTop: 40,
|
||||||
|
paddingLeft: globals.leftSidebarSectionPadding
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Continuous metadata
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{obsAnnotations
|
||||||
|
? _.map(obsAnnotations.colIndex.keys(), key => {
|
||||||
|
const isColorField =
|
||||||
|
key.includes("color") || key.includes("Color");
|
||||||
|
if (key === "name" || isColorField) return null;
|
||||||
|
|
||||||
|
const summary = obsAnnotations.col(key).summarize();
|
||||||
|
const nonFiniteExtent =
|
||||||
|
summary.min === undefined || summary.max === undefined;
|
||||||
|
if (!summary.categorical && !nonFiniteExtent) {
|
||||||
|
zebra += 1;
|
||||||
|
return (
|
||||||
|
<HistogramBrush
|
||||||
|
key={key}
|
||||||
|
field={key}
|
||||||
|
isObs
|
||||||
|
zebra={zebra % 2 === 0}
|
||||||
|
ranges={summary}
|
||||||
|
handleColorAction={this.handleColorAction(key).bind(this)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Continuous;
|
||||||
+10
-15
@@ -1,4 +1,5 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
|
import * as d3 from "d3";
|
||||||
import styles from "./parallelCoordinates.css";
|
import styles from "./parallelCoordinates.css";
|
||||||
import { yAxis, brushstart } from "./util";
|
import { yAxis, brushstart } from "./util";
|
||||||
|
|
||||||
@@ -19,13 +20,11 @@ const drawAxes = (
|
|||||||
******************************************/
|
******************************************/
|
||||||
|
|
||||||
function brush() {
|
function brush() {
|
||||||
var actives = [];
|
const actives = [];
|
||||||
svg
|
svg
|
||||||
.selectAll(".parcoords_axis .parcoords_brush")
|
.selectAll(".parcoords_axis .parcoords_brush")
|
||||||
.filter(function(d) {
|
.filter(() => d3.brushSelection(this))
|
||||||
return d3.brushSelection(this);
|
.each(d => {
|
||||||
})
|
|
||||||
.each(function(d) {
|
|
||||||
actives.push({
|
actives.push({
|
||||||
dimension: d,
|
dimension: d,
|
||||||
extent: d3.brushSelection(this)
|
extent: d3.brushSelection(this)
|
||||||
@@ -35,20 +34,18 @@ const drawAxes = (
|
|||||||
handleBrushAction(actives);
|
handleBrushAction(actives);
|
||||||
}
|
}
|
||||||
|
|
||||||
var axes = svg
|
const axes = svg
|
||||||
.selectAll(".parcoords_axis")
|
.selectAll(".parcoords_axis")
|
||||||
.data(dimensions)
|
.data(dimensions)
|
||||||
.enter()
|
.enter()
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("class", `${styles.axis} parcoords_axis`)
|
.attr("class", `${styles.axis} parcoords_axis`)
|
||||||
.attr("transform", (d, i) => {
|
.attr("transform", (d, i) => `translate(${xscale(i)})`);
|
||||||
return "translate(" + xscale(i) + ")";
|
|
||||||
});
|
|
||||||
|
|
||||||
axes
|
axes
|
||||||
.append("g")
|
.append("g")
|
||||||
.each(function(d) {
|
.each(d => {
|
||||||
var renderAxis =
|
const renderAxis =
|
||||||
"axis" in d
|
"axis" in d
|
||||||
? d.axis.scale(d.scale) // custom axis
|
? d.axis.scale(d.scale) // custom axis
|
||||||
: yAxis.scale(d.scale); // default axis
|
: yAxis.scale(d.scale); // default axis
|
||||||
@@ -60,15 +57,13 @@ const drawAxes = (
|
|||||||
})
|
})
|
||||||
.attr("class", styles.title)
|
.attr("class", styles.title)
|
||||||
.attr("text-anchor", "start")
|
.attr("text-anchor", "start")
|
||||||
.text(function(d) {
|
.text(d => ("description" in d ? `${d.description} 🖌️` : `${d.key} 🖌️`));
|
||||||
return "description" in d ? d.description + " 🖌️" : d.key + " 🖌️";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add and store a brush for each axis.
|
// Add and store a brush for each axis.
|
||||||
axes
|
axes
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("class", `${styles.brush} parcoords_brush`)
|
.attr("class", `${styles.brush} parcoords_brush`)
|
||||||
.each(function(d) {
|
.each(d => {
|
||||||
d3.select(this).call(
|
d3.select(this).call(
|
||||||
(d.brush = d3
|
(d.brush = d3
|
||||||
.brushY()
|
.brushY()
|
||||||
+20
-20
@@ -7,24 +7,24 @@ body {
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.parcoords {
|
:local(.parcoords) {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parcoords svg,
|
:local(.parcoords) svg,
|
||||||
.parcoords canvas {
|
:local(.parcoords) canvas {
|
||||||
font: 10px sans-serif;
|
font: 10px sans-serif;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parcoords canvas {
|
:local(.parcoords) canvas {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis .title {
|
:local(.axis .title) {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
transform: rotate(-21deg) translate(-5px,-6px);
|
transform: rotate(-21deg) translate(-5px, -6px);
|
||||||
fill: #222;
|
fill: #222;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -32,14 +32,14 @@ body {
|
|||||||
/* -webkit-filter: grayscale(100%);
|
/* -webkit-filter: grayscale(100%);
|
||||||
filter: grayscale(100%); */
|
filter: grayscale(100%); */
|
||||||
|
|
||||||
.axis line,
|
:local(.axis) line,
|
||||||
.axis path {
|
:local(.axis) path {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #ccc;
|
stroke: #ccc;
|
||||||
stroke-width: 1px;
|
stroke-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis .tick text {
|
:local(.axis .tick) text {
|
||||||
fill: #222;
|
fill: #222;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -52,39 +52,39 @@ old, from reference bl.ocks
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.axis:hover line,
|
:local(.axis:hover) line,
|
||||||
.axis:hover path,
|
:local(.axis:hover) path,
|
||||||
.axis.active line,
|
:local(.axis.active) line,
|
||||||
.axis.active path {
|
:local(.axis.active) path {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #222;
|
stroke: #222;
|
||||||
stroke-width: 1px;
|
stroke-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis:hover .title {
|
:local(.axis:hover .title) {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis:hover .tick text {
|
:local(.axis:hover .tick) text {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis.active .title {
|
:local(.axis.active .title) {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis.active .tick text {
|
:local(.axis.active .tick) text {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brush .extent {
|
:local(.brush .extent) {
|
||||||
fill-opacity: .3;
|
fill-opacity: 0.3;
|
||||||
stroke: #fff;
|
stroke: #fff;
|
||||||
stroke-width: 1px;
|
stroke-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pre {
|
:local(.pre) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
margin: 6px 12px;
|
margin: 6px 12px;
|
||||||
+12
-10
@@ -4,26 +4,28 @@
|
|||||||
******************************************
|
******************************************
|
||||||
******************************************/
|
******************************************/
|
||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
const setupParallelCoordinates = (width, height, margin) => {
|
import * as d3 from "d3";
|
||||||
var container = d3.select("#parcoords");
|
|
||||||
|
|
||||||
var svg = container
|
const setupParallelCoordinates = (width, height, margin) => {
|
||||||
|
const container = d3.select("#parcoords");
|
||||||
|
|
||||||
|
const svg = container
|
||||||
.append("svg")
|
.append("svg")
|
||||||
.attr("width", width + margin.left + margin.right)
|
.attr("width", width + margin.left + margin.right)
|
||||||
.attr("height", height + margin.top + margin.bottom)
|
.attr("height", height + margin.top + margin.bottom)
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
var canvas = container
|
const canvas = container
|
||||||
.append("canvas")
|
.append("canvas")
|
||||||
.attr("width", width * devicePixelRatio)
|
.attr("width", width * devicePixelRatio)
|
||||||
.attr("height", height * devicePixelRatio)
|
.attr("height", height * devicePixelRatio)
|
||||||
.style("width", width + "px")
|
.style("width", `${width}px`)
|
||||||
.style("height", height + "px")
|
.style("height", `${height}px`)
|
||||||
.style("margin-top", margin.top + "px")
|
.style("margin-top", `${margin.top}px`)
|
||||||
.style("margin-left", margin.left + "px");
|
.style("margin-left", `${margin.left}px`);
|
||||||
|
|
||||||
var ctx = canvas.node().getContext("2d");
|
const ctx = canvas.node().getContext("2d");
|
||||||
ctx.globalCompositeOperation = "darken";
|
ctx.globalCompositeOperation = "darken";
|
||||||
ctx.globalAlpha = 0.15;
|
ctx.globalAlpha = 0.15;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
|
||||||
const paddingRight = 120;
|
const paddingRight = 120;
|
||||||
const continuousChartWidth = 1200;
|
const continuousChartWidth = 1200;
|
||||||
@@ -17,11 +18,10 @@ export const createDimensions = data => {
|
|||||||
_.each(data, (value, key) => {
|
_.each(data, (value, key) => {
|
||||||
if (value.range) {
|
if (value.range) {
|
||||||
newArr.push({
|
newArr.push({
|
||||||
key: key /* room for confusion: lodash calls this key, it's also the name of the property parallel coords code is looking for */,
|
key /* room for confusion: lodash calls this key, it's also the name of the property parallel coords code is looking for */,
|
||||||
type: {
|
type: {
|
||||||
within: (d, extent, dim) => {
|
within: (d, extent, dim) =>
|
||||||
return extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1];
|
extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1]
|
||||||
}
|
|
||||||
},
|
},
|
||||||
scale: d3
|
scale: d3
|
||||||
.scaleSqrt()
|
.scaleSqrt()
|
||||||
@@ -39,19 +39,12 @@ export const brushstart = () => {
|
|||||||
d3.event.sourceEvent.stopPropagation();
|
d3.event.sourceEvent.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const d3_functor = v => {
|
// Unused.
|
||||||
return typeof v === "function"
|
// export const d3_functor = v => (typeof v === "function" ? v : () => v);
|
||||||
? v
|
export const project = (d, dimensions, xscale) =>
|
||||||
: () => {
|
dimensions.map((p, i) => {
|
||||||
return v;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const project = (d, dimensions, xscale) => {
|
|
||||||
return dimensions.map((p, i) => {
|
|
||||||
// check if data element has property and contains a value
|
// check if data element has property and contains a value
|
||||||
if (!(p.key in d) || d[p.key] === null) return null;
|
if (!(p.key in d) || d[p.key] === null) return null;
|
||||||
|
|
||||||
return [xscale(i), p.scale(d[p.key])];
|
return [xscale(i), p.scale(d[p.key])];
|
||||||
});
|
});
|
||||||
};
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import React from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
import { interpolateViridis, interpolateCool } from "d3-scale-chromatic";
|
||||||
|
|
||||||
|
// create continuous color legend
|
||||||
|
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
||||||
|
const continuous = (selectorId, colorscale, colorAccessor) => {
|
||||||
|
const legendheight = 200;
|
||||||
|
const legendwidth = 80;
|
||||||
|
const margin = { top: 10, right: 60, bottom: 10, left: 2 };
|
||||||
|
|
||||||
|
const canvas = d3
|
||||||
|
.select(selectorId)
|
||||||
|
.style("height", `${legendheight}px`)
|
||||||
|
.style("width", `${legendwidth}px`)
|
||||||
|
.append("canvas")
|
||||||
|
.attr("height", legendheight - margin.top - margin.bottom)
|
||||||
|
.attr("width", 1)
|
||||||
|
.style("height", `${legendheight - margin.top - margin.bottom}px`)
|
||||||
|
.style("width", `${legendwidth - margin.left - margin.right}px`)
|
||||||
|
.style("position", "absolute")
|
||||||
|
.style("top", `${margin.top + 1}px`)
|
||||||
|
.style("left", `${margin.left + 1}px`)
|
||||||
|
.style(
|
||||||
|
"transform",
|
||||||
|
"scale(1,-1)"
|
||||||
|
) /* flip it! dark is high value light is low.
|
||||||
|
we flip the color scale as well [1, 0] instead of [0, 1] */
|
||||||
|
.node();
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
const legendscale = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.range([1, legendheight - margin.top - margin.bottom])
|
||||||
|
.domain([
|
||||||
|
colorscale.domain()[1],
|
||||||
|
colorscale.domain()[0]
|
||||||
|
]); /* we flip this to make viridis colors dark if high in the color scale */
|
||||||
|
|
||||||
|
// image data hackery based on http://bl.ocks.org/mbostock/048d21cf747371b11884f75ad896e5a5
|
||||||
|
const image = ctx.createImageData(1, legendheight);
|
||||||
|
d3.range(legendheight).forEach(i => {
|
||||||
|
const c = d3.rgb(colorscale(legendscale.invert(i)));
|
||||||
|
image.data[4 * i] = c.r;
|
||||||
|
image.data[4 * i + 1] = c.g;
|
||||||
|
image.data[4 * i + 2] = c.b;
|
||||||
|
image.data[4 * i + 3] = 255;
|
||||||
|
});
|
||||||
|
ctx.putImageData(image, 0, 0);
|
||||||
|
|
||||||
|
// A simpler way to do the above, but possibly slower. keep in mind the legend
|
||||||
|
// width is stretched because the width attr of the canvas is 1
|
||||||
|
// See http://stackoverflow.com/questions/4899799/whats-the-best-way-to-set-a-single-pixel-in-an-html5-canvas
|
||||||
|
/*
|
||||||
|
d3.range(legendheight).forEach(function(i) {
|
||||||
|
ctx.fillStyle = colorscale(legendscale.invert(i));
|
||||||
|
ctx.fillRect(0,i,1,1);
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
const legendaxis = d3
|
||||||
|
.axisRight()
|
||||||
|
.scale(legendscale)
|
||||||
|
.tickSize(6)
|
||||||
|
.ticks(8);
|
||||||
|
|
||||||
|
const svg = d3
|
||||||
|
.select(selectorId)
|
||||||
|
.append("svg")
|
||||||
|
.attr("height", `${legendheight}px`)
|
||||||
|
.attr("width", `${legendwidth}px`)
|
||||||
|
.style("position", "absolute")
|
||||||
|
.style("left", "0px")
|
||||||
|
.style("top", "0px");
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "axis")
|
||||||
|
.attr(
|
||||||
|
"transform",
|
||||||
|
`translate(${legendwidth - margin.left - margin.right + 3},${margin.top})`
|
||||||
|
)
|
||||||
|
.call(legendaxis);
|
||||||
|
|
||||||
|
// text label for the y axis
|
||||||
|
svg
|
||||||
|
.append("text")
|
||||||
|
.attr("transform", "rotate(-90)")
|
||||||
|
.attr("y", 2)
|
||||||
|
.attr("x", 0 - legendheight / 2)
|
||||||
|
.attr("dy", "1em")
|
||||||
|
.style("text-anchor", "middle")
|
||||||
|
.style("fill", "white")
|
||||||
|
.text(colorAccessor);
|
||||||
|
};
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
colorAccessor: state.colors.colorAccessor,
|
||||||
|
colorScale: state.colors.scale,
|
||||||
|
responsive: state.responsive
|
||||||
|
}))
|
||||||
|
class ContinuousLegend extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const { colorAccessor, responsive, colorScale } = this.props;
|
||||||
|
if (
|
||||||
|
prevProps.colorAccessor !== colorAccessor ||
|
||||||
|
prevProps.colorScale !== colorScale ||
|
||||||
|
prevProps.responsive.height !== responsive.height ||
|
||||||
|
prevProps.responsive.width !== responsive.width
|
||||||
|
) {
|
||||||
|
/* always remove it, if it's not continuous we don't put it back. */
|
||||||
|
d3.select("#continuous_legend")
|
||||||
|
.selectAll("*")
|
||||||
|
.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colorAccessor && colorScale && colorScale.range) {
|
||||||
|
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
|
||||||
|
if (colorScale.range()[0][0] !== "#") {
|
||||||
|
continuous(
|
||||||
|
"#continuous_legend",
|
||||||
|
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
|
||||||
|
colorAccessor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { colorAccessor, responsive } = this.props;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="continuous_legend"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
display: colorAccessor ? "inherit" : "none",
|
||||||
|
right: 0,
|
||||||
|
top: responsive.height / 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContinuousLegend;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.btn {
|
:local(.btn) {
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: "#FFF";
|
color: "#FFF";
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
.container {
|
:local(.container) {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import styles from "./container.css";
|
||||||
|
|
||||||
|
const Container = props => {
|
||||||
|
const { children } = props;
|
||||||
|
return <div className={styles.container}>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Container;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Position, Toaster, Intent } from "@blueprintjs/core";
|
||||||
|
|
||||||
|
/** Singleton toaster instance. Create separate instances for different options. */
|
||||||
|
|
||||||
|
const ErrorToastTopCenter = Toaster.create({
|
||||||
|
className: "recipe-toaster",
|
||||||
|
position: Position.TOP
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
A "user" error - eg, bad input
|
||||||
|
*/
|
||||||
|
export const postUserErrorToast = message =>
|
||||||
|
ErrorToastTopCenter.show({ message, intent: Intent.WARNING });
|
||||||
|
|
||||||
|
/*
|
||||||
|
A toast the user must dismiss manually, because they need to act on its information,
|
||||||
|
ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix.
|
||||||
|
*/
|
||||||
|
export const keepAroundErrorToast = message =>
|
||||||
|
ErrorToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING });
|
||||||
|
|
||||||
|
/*
|
||||||
|
a hard network error
|
||||||
|
*/
|
||||||
|
export const postNetworkErrorToast = message =>
|
||||||
|
ErrorToastTopCenter.show({
|
||||||
|
message,
|
||||||
|
timeout: 30000,
|
||||||
|
intent: Intent.DANGER
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import React from "react";
|
||||||
|
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { World } from "../../util/stateManager";
|
||||||
|
|
||||||
|
@connect()
|
||||||
|
class CellSetButton extends React.Component {
|
||||||
|
set() {
|
||||||
|
const {
|
||||||
|
differential,
|
||||||
|
crossfilter,
|
||||||
|
dispatch,
|
||||||
|
eitherCellSetOneOrTwo
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
// Reducer and components assume that value will be null if
|
||||||
|
// no selection made. World..getSelectedByIndex() returns a
|
||||||
|
// zero length TypedArray when nothing is selected.
|
||||||
|
let set = World.getSelectedByIndex(crossfilter);
|
||||||
|
if (set.length === 0) set = null;
|
||||||
|
|
||||||
|
if (!differential.diffExp) {
|
||||||
|
/* diffexp needs to be cleared before we store a new set */
|
||||||
|
dispatch({
|
||||||
|
type: `store current cell selection as differential set ${eitherCellSetOneOrTwo}`,
|
||||||
|
data: set
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { differential, eitherCellSetOneOrTwo } = this.props;
|
||||||
|
const cellListName = `celllist${eitherCellSetOneOrTwo}`;
|
||||||
|
let cells_selected = differential[cellListName]
|
||||||
|
? differential[cellListName].length
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content="Save current selection for differential expression computation"
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<AnchorButton
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
type="button"
|
||||||
|
disabled={differential.diffExp}
|
||||||
|
onClick={this.set.bind(this)}
|
||||||
|
data-testid={`cellset-button-${eitherCellSetOneOrTwo}`}
|
||||||
|
>
|
||||||
|
{eitherCellSetOneOrTwo}
|
||||||
|
{": "}
|
||||||
|
<span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}>
|
||||||
|
{cells_selected}
|
||||||
|
</span>
|
||||||
|
{" cells"}
|
||||||
|
</AnchorButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CellSetButton;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import React from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { Button, AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
import actions from "../../actions";
|
||||||
|
import CellSetButton from "./cellSetButtons";
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
differential: state.differential,
|
||||||
|
world: state.world,
|
||||||
|
crossfilter: state.crossfilter
|
||||||
|
}))
|
||||||
|
class Expression extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
computeDiffExp() {
|
||||||
|
const { dispatch, differential } = this.props;
|
||||||
|
if (differential.celllist1 && differential.celllist2) {
|
||||||
|
dispatch(
|
||||||
|
actions.requestDifferentialExpression(
|
||||||
|
differential.celllist1,
|
||||||
|
differential.celllist2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDifferentialExpression() {
|
||||||
|
const { dispatch, differential } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "clear differential expression",
|
||||||
|
diffExp: differential.diffExp
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "clear scatterplot"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { differential } = this.props;
|
||||||
|
if (!differential) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const haveBothCellSets =
|
||||||
|
!!differential.celllist1 && !!differential.celllist2;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginRight: 10,
|
||||||
|
marginBottom: 10,
|
||||||
|
paddingLeft: globals.leftSidebarSectionPadding
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
|
||||||
|
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
|
||||||
|
{!differential.diffExp ? (
|
||||||
|
<Tooltip
|
||||||
|
content="Add two cells selections, see the top 15 differentially expressed genes between them"
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<AnchorButton
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
disabled={!haveBothCellSets}
|
||||||
|
intent="primary"
|
||||||
|
data-testid="diffexp-button"
|
||||||
|
loading={differential.loading}
|
||||||
|
fill
|
||||||
|
type="button"
|
||||||
|
onClick={this.computeDiffExp.bind(this)}
|
||||||
|
>
|
||||||
|
Compute Differential Expression
|
||||||
|
</AnchorButton>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{differential.diffExp ? (
|
||||||
|
<Tooltip
|
||||||
|
content="Remove differentially expressed gene list and clear cell selections"
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
fill
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
intent="warning"
|
||||||
|
onClick={this.clearDifferentialExpression.bind(this)}
|
||||||
|
>
|
||||||
|
Clear Differential Expression
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Expression;
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import fuzzysort from "fuzzysort";
|
||||||
|
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import {
|
||||||
|
MenuItem,
|
||||||
|
Button,
|
||||||
|
FormGroup,
|
||||||
|
InputGroup,
|
||||||
|
ControlGroup
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import { Suggest } from "@blueprintjs/select";
|
||||||
|
import HistogramBrush from "../brushableHistogram";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
import actions from "../../actions";
|
||||||
|
import {
|
||||||
|
postUserErrorToast,
|
||||||
|
keepAroundErrorToast
|
||||||
|
} from "../framework/toasters";
|
||||||
|
import ExpressionButtons from "./expressionButtons";
|
||||||
|
|
||||||
|
const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
|
||||||
|
if (!modifiers.matchesPredicate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/* the fuzzysort wraps the object with other properties, like a score */
|
||||||
|
const geneName = fuzzySortResult.target;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
active={modifiers.active}
|
||||||
|
disabled={modifiers.disabled}
|
||||||
|
data-testid={`suggest-menu-item-${geneName}`}
|
||||||
|
// Use of annotations in this way is incorrect and dataset specific.
|
||||||
|
// See https://github.com/chanzuckerberg/cellxgene/issues/483
|
||||||
|
// label={gene.n_counts}
|
||||||
|
key={geneName}
|
||||||
|
onClick={g =>
|
||||||
|
/* this fires when user clicks a menu item */
|
||||||
|
handleClick(g)
|
||||||
|
}
|
||||||
|
text={geneName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterGenes = (query, genes) =>
|
||||||
|
/* fires on load, once, and then for each character typed into the input */
|
||||||
|
fuzzysort.go(query, genes, {
|
||||||
|
limit: 5,
|
||||||
|
threshold: -10000 // don't return bad results
|
||||||
|
});
|
||||||
|
|
||||||
|
@connect(state => {
|
||||||
|
return {
|
||||||
|
obsAnnotations: _.get(state.world, "obsAnnotations", null),
|
||||||
|
userDefinedGenes: state.controls.userDefinedGenes,
|
||||||
|
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||||
|
world: state.world,
|
||||||
|
colorAccessor: state.colors.colorAccessor,
|
||||||
|
differential: state.differential
|
||||||
|
};
|
||||||
|
})
|
||||||
|
class GeneExpression extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
bulkAdd: "",
|
||||||
|
tab: "autosuggest"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholderGeneNames() {
|
||||||
|
/*
|
||||||
|
return a string containing gene name suggestions for use as a user hint.
|
||||||
|
Eg., Apod, Cd74, ...
|
||||||
|
Will return a max of 3 genes, totalling 15 characters in length.
|
||||||
|
Randomly selects gene names.
|
||||||
|
|
||||||
|
NOTE: the random selection means it will re-render constantly.
|
||||||
|
*/
|
||||||
|
const { world } = this.props;
|
||||||
|
const { varAnnotations } = world;
|
||||||
|
const geneNames = varAnnotations.col("name").asArray();
|
||||||
|
if (geneNames.length > 0) {
|
||||||
|
const placeholder = [];
|
||||||
|
let len = geneNames.length;
|
||||||
|
const maxGeneNameCount = 3;
|
||||||
|
const maxStrLength = 15;
|
||||||
|
len = len < maxGeneNameCount ? len : maxGeneNameCount;
|
||||||
|
for (let i = 0, strLen = 0; i < len && strLen < maxStrLength; i += 1) {
|
||||||
|
const deal = Math.floor(Math.random() * geneNames.length);
|
||||||
|
const geneName = geneNames[deal];
|
||||||
|
placeholder.push(geneName);
|
||||||
|
strLen += geneName.length + 2; // '2' is the length of a comma and space
|
||||||
|
}
|
||||||
|
placeholder.push("...");
|
||||||
|
return placeholder.join(", ");
|
||||||
|
}
|
||||||
|
// default - should never happen.
|
||||||
|
return "Apod, Cd74, ...";
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(g) {
|
||||||
|
const { world, dispatch, userDefinedGenes } = this.props;
|
||||||
|
const gene = g.target;
|
||||||
|
if (userDefinedGenes.indexOf(gene) !== -1) {
|
||||||
|
postUserErrorToast("That gene already exists");
|
||||||
|
} else if (userDefinedGenes.length > 15) {
|
||||||
|
postUserErrorToast(
|
||||||
|
"That's too many genes, you can have at most 15 user defined genes"
|
||||||
|
);
|
||||||
|
} else if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
|
||||||
|
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||||
|
} else {
|
||||||
|
dispatch({ type: "single user defined gene start" });
|
||||||
|
dispatch(actions.requestUserDefinedGene(gene)).then(
|
||||||
|
() => dispatch({ type: "single user defined gene complete" }),
|
||||||
|
() => dispatch({ type: "single user defined gene error" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBulkAddClick() {
|
||||||
|
const { world, dispatch, userDefinedGenes } = this.props;
|
||||||
|
const { bulkAdd } = this.state;
|
||||||
|
|
||||||
|
/*
|
||||||
|
test:
|
||||||
|
Apod,,, Cd74,, ,,, Foo, Bar-2,,
|
||||||
|
*/
|
||||||
|
if (bulkAdd !== "") {
|
||||||
|
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
||||||
|
|
||||||
|
dispatch({ type: "bulk user defined gene start" });
|
||||||
|
Promise.all(
|
||||||
|
genes.map(gene => {
|
||||||
|
if (gene.length === 0) {
|
||||||
|
return keepAroundErrorToast("Must enter a gene name.");
|
||||||
|
}
|
||||||
|
if (userDefinedGenes.indexOf(gene) !== -1) {
|
||||||
|
return keepAroundErrorToast("That gene already exists");
|
||||||
|
}
|
||||||
|
if (world.varAnnotations.col("name").indexOf(gene) === undefined) {
|
||||||
|
return keepAroundErrorToast(
|
||||||
|
`${gene} doesn't appear to be a valid gene name.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dispatch(actions.requestUserDefinedGene(gene));
|
||||||
|
})
|
||||||
|
).then(
|
||||||
|
() => dispatch({ type: "bulk user defined gene complete" }),
|
||||||
|
() => dispatch({ type: "bulk user defined gene error" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ bulkAdd: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
world,
|
||||||
|
userDefinedGenes,
|
||||||
|
userDefinedGenesLoading,
|
||||||
|
differential
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const { tab, bulkAdd } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 30
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={Object.assign({}, globals.leftSidebarSectionHeading, {
|
||||||
|
paddingLeft: globals.leftSidebarSectionPadding,
|
||||||
|
margin: 0
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Selected Genes
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: globals.leftSidebarSectionPadding
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
active={tab === "autosuggest"}
|
||||||
|
style={{ marginRight: 5 }}
|
||||||
|
minimal
|
||||||
|
small
|
||||||
|
data-testid="tab-autosuggest"
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ tab: "autosuggest" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Autosuggest
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
active={tab === "bulkadd"}
|
||||||
|
minimal
|
||||||
|
small
|
||||||
|
data-testid="section-bulk-add"
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ tab: "bulkadd" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bulk add genes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "autosuggest" ? (
|
||||||
|
<ControlGroup
|
||||||
|
style={{
|
||||||
|
paddingLeft: globals.leftSidebarSectionPadding,
|
||||||
|
paddingBottom: globals.leftSidebarSectionPadding
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Suggest
|
||||||
|
resetOnSelect
|
||||||
|
closeOnSelect
|
||||||
|
resetOnClose
|
||||||
|
itemDisabled={
|
||||||
|
userDefinedGenesLoading ? () => true : () => false
|
||||||
|
}
|
||||||
|
noResults={<MenuItem disabled text="No matching genes." />}
|
||||||
|
onItemSelect={g => {
|
||||||
|
/* this happens on 'enter' */
|
||||||
|
this.handleClick(g);
|
||||||
|
}}
|
||||||
|
inputProps={{ "data-testid": "gene-search" }}
|
||||||
|
inputValueRenderer={g => {
|
||||||
|
return "";
|
||||||
|
}}
|
||||||
|
itemListPredicate={filterGenes}
|
||||||
|
itemRenderer={renderGene.bind(this)}
|
||||||
|
items={
|
||||||
|
world && world.varAnnotations
|
||||||
|
? world.varAnnotations.col("name").asArray()
|
||||||
|
: ["No genes"]
|
||||||
|
}
|
||||||
|
popoverProps={{ minimal: true }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="bp3-button bp3-intent-primary"
|
||||||
|
data-testid={"add-gene"}
|
||||||
|
loading={userDefinedGenesLoading}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</ControlGroup>
|
||||||
|
) : null}
|
||||||
|
{tab === "bulkadd" ? (
|
||||||
|
<div style={{ paddingLeft: globals.leftSidebarSectionPadding }}>
|
||||||
|
<form
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleBulkAddClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormGroup
|
||||||
|
helperText="Add a list of genes (comma delimited)"
|
||||||
|
labelFor="text-input-bulk-add"
|
||||||
|
>
|
||||||
|
<ControlGroup>
|
||||||
|
<InputGroup
|
||||||
|
onChange={e => {
|
||||||
|
this.setState({ bulkAdd: e.target.value });
|
||||||
|
}}
|
||||||
|
id="text-input-bulk-add"
|
||||||
|
data-testid="input-bulk-add"
|
||||||
|
placeholder={this.placeholderGeneNames()}
|
||||||
|
value={bulkAdd}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
intent="primary"
|
||||||
|
onClick={this.handleBulkAddClick.bind(this)}
|
||||||
|
loading={userDefinedGenesLoading}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</ControlGroup>
|
||||||
|
</FormGroup>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{world && userDefinedGenes.length > 0
|
||||||
|
? _.map(userDefinedGenes, (geneName, index) => {
|
||||||
|
const values = world.varData.col(geneName);
|
||||||
|
if (!values) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const summary = values.summarize();
|
||||||
|
return (
|
||||||
|
<HistogramBrush
|
||||||
|
key={geneName}
|
||||||
|
field={geneName}
|
||||||
|
zebra={index % 2 === 0}
|
||||||
|
ranges={summary}
|
||||||
|
isUserDefined
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={Object.assign({}, globals.leftSidebarSectionHeading, {
|
||||||
|
marginTop: 40,
|
||||||
|
paddingLeft: globals.leftSidebarSectionPadding
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Differentially Expressed Genes
|
||||||
|
</p>
|
||||||
|
<ExpressionButtons />
|
||||||
|
{differential.diffExp
|
||||||
|
? _.map(differential.diffExp, (value, index) => {
|
||||||
|
const name = world.varAnnotations.at(value[0], "name");
|
||||||
|
const values = world.varData.col(name);
|
||||||
|
if (!values) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const summary = values.summarize();
|
||||||
|
return (
|
||||||
|
<HistogramBrush
|
||||||
|
key={name}
|
||||||
|
field={name}
|
||||||
|
zebra={index % 2 === 0}
|
||||||
|
ranges={summary}
|
||||||
|
isDiffExp
|
||||||
|
logFoldChange={value[1]}
|
||||||
|
pval={value[2]}
|
||||||
|
pvalAdj={value[3]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GeneExpression;
|
||||||
+1
-9
@@ -38,15 +38,7 @@ export default function(regl) {
|
|||||||
uniforms: {
|
uniforms: {
|
||||||
distance: regl.prop("distance"),
|
distance: regl.prop("distance"),
|
||||||
view: regl.prop("view"),
|
view: regl.prop("view"),
|
||||||
projection: (context, props) => {
|
projection: ({viewportWidth, viewportHeight}) => mat4.perspective([], Math.PI / 2, viewportWidth / viewportHeight, 0.01, 1000)
|
||||||
return mat4.perspective(
|
|
||||||
[],
|
|
||||||
Math.PI / 2,
|
|
||||||
context.viewportWidth * props.scale / context.viewportHeight,
|
|
||||||
0.01,
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
count: regl.prop("count"),
|
count: regl.prop("count"),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.graphCanvas path {
|
:local(.graphCanvas) path {
|
||||||
shape-rendering: crispEdges;
|
shape-rendering: crispEdges;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis path,
|
:local(.axis) path,
|
||||||
.axis line {
|
:local(.axis) line {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #000;
|
stroke: #000;
|
||||||
stroke-width: 1px;
|
stroke-width: 1px;
|
||||||
@@ -29,6 +29,6 @@ circle {
|
|||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
:local(.hidden) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
|||||||
|
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
|
||||||
|
|
||||||
|
import * as d3 from "d3";
|
||||||
|
|
||||||
|
const Lasso = () => {
|
||||||
|
const dispatch = d3.dispatch("start", "end", "cancel");
|
||||||
|
|
||||||
|
const polygonToPath = polygon =>
|
||||||
|
`M${polygon.map(d => d.join(",")).join("L")}`;
|
||||||
|
|
||||||
|
const distance = (pt1, pt2) =>
|
||||||
|
Math.sqrt((pt2[0] - pt1[0]) ** 2 + (pt2[1] - pt1[1]) ** 2);
|
||||||
|
|
||||||
|
// distance last point has to be to first point before it auto closes when mouse is released
|
||||||
|
const closeDistance = 75;
|
||||||
|
|
||||||
|
const lasso = svg => {
|
||||||
|
let lassoPolygon;
|
||||||
|
let lassoPath;
|
||||||
|
let closePath;
|
||||||
|
|
||||||
|
const handleDragStart = () => {
|
||||||
|
lassoPolygon = [d3.mouse(svg.node())]; // current x y of mouse within element
|
||||||
|
|
||||||
|
if (lassoPath) {
|
||||||
|
lassoPath.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
lassoPath = g
|
||||||
|
.append("path")
|
||||||
|
.attr("data-testid", "lasso-element")
|
||||||
|
.attr("fill", "#0bb")
|
||||||
|
.attr("fill-opacity", 0.1)
|
||||||
|
.attr("stroke", "#0bb")
|
||||||
|
.attr("stroke-dasharray", "3, 3");
|
||||||
|
|
||||||
|
closePath = g
|
||||||
|
.append("line")
|
||||||
|
.attr("x2", lassoPolygon[0][0])
|
||||||
|
.attr("y2", lassoPolygon[0][1])
|
||||||
|
.attr("stroke", "#0bb")
|
||||||
|
.attr("stroke-dasharray", "3, 3")
|
||||||
|
.attr("opacity", 0);
|
||||||
|
|
||||||
|
dispatch.call("start", lasso, lassoPolygon);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = () => {
|
||||||
|
const point = d3.mouse(svg.node());
|
||||||
|
lassoPolygon.push(point);
|
||||||
|
lassoPath.attr("d", polygonToPath(lassoPolygon));
|
||||||
|
|
||||||
|
// indicate if we are within closing distance
|
||||||
|
if (
|
||||||
|
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
|
||||||
|
closeDistance
|
||||||
|
) {
|
||||||
|
closePath
|
||||||
|
.attr("x1", point[0])
|
||||||
|
.attr("y1", point[1])
|
||||||
|
.attr("opacity", 1);
|
||||||
|
} else {
|
||||||
|
closePath.attr("opacity", 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
// remove the close path
|
||||||
|
closePath.remove();
|
||||||
|
closePath = null;
|
||||||
|
|
||||||
|
// succesfully closed
|
||||||
|
if (
|
||||||
|
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
|
||||||
|
closeDistance
|
||||||
|
) {
|
||||||
|
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
|
||||||
|
dispatch.call("end", lasso, lassoPolygon);
|
||||||
|
|
||||||
|
// otherwise cancel
|
||||||
|
} else {
|
||||||
|
lassoPath.remove();
|
||||||
|
lassoPath = null;
|
||||||
|
lassoPolygon = null;
|
||||||
|
dispatch.call("cancel");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// append a <g> with a rect
|
||||||
|
const g = svg.append("g").attr("class", "lasso-group");
|
||||||
|
const bbox = svg.node().getBoundingClientRect();
|
||||||
|
const area = g
|
||||||
|
.append("rect")
|
||||||
|
.attr("width", bbox.width)
|
||||||
|
.attr("height", bbox.height)
|
||||||
|
.attr("fill", "tomato")
|
||||||
|
.attr("opacity", 0);
|
||||||
|
|
||||||
|
const drag = d3
|
||||||
|
.drag()
|
||||||
|
.on("start", handleDragStart)
|
||||||
|
.on("drag", handleDrag)
|
||||||
|
.on("end", handleDragEnd);
|
||||||
|
|
||||||
|
area.call(drag);
|
||||||
|
|
||||||
|
lasso.reset = () => {
|
||||||
|
if (lassoPath) {
|
||||||
|
lassoPath.remove();
|
||||||
|
lassoPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lassoPolygon = null;
|
||||||
|
if (closePath) {
|
||||||
|
closePath.remove();
|
||||||
|
closePath = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lasso.move = polygon => {
|
||||||
|
if (polygon !== lassoPolygon || polygon.length !== lassoPolygon.length) {
|
||||||
|
lasso.reset();
|
||||||
|
|
||||||
|
lassoPolygon = polygon;
|
||||||
|
lassoPath = g
|
||||||
|
.append("path")
|
||||||
|
.attr("data-testid", "lasso-element")
|
||||||
|
.attr("fill", "#0bb")
|
||||||
|
.attr("fill-opacity", 0.1)
|
||||||
|
.attr("stroke", "#0bb")
|
||||||
|
.attr("stroke-dasharray", "3, 3");
|
||||||
|
|
||||||
|
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
lasso.on = (type, callback) => {
|
||||||
|
dispatch.on(type, callback);
|
||||||
|
return lasso;
|
||||||
|
};
|
||||||
|
|
||||||
|
return lasso;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Lasso;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import * as d3 from "d3";
|
||||||
|
import styles from "./graph.css";
|
||||||
|
import Lasso from "./setupLasso";
|
||||||
|
|
||||||
|
/******************************************
|
||||||
|
*******************************************
|
||||||
|
put svg & brush in DOM
|
||||||
|
*******************************************
|
||||||
|
******************************************/
|
||||||
|
|
||||||
|
export default (
|
||||||
|
selectionToolType,
|
||||||
|
handleStartAction,
|
||||||
|
handleDragAction,
|
||||||
|
handleEndAction,
|
||||||
|
handleCancelAction,
|
||||||
|
responsive,
|
||||||
|
graphPaddingRight
|
||||||
|
) => {
|
||||||
|
const svg = d3
|
||||||
|
.select("#graphAttachPoint")
|
||||||
|
.append("svg")
|
||||||
|
.attr("data-testid", "layout-overlay")
|
||||||
|
.attr("width", responsive.width - graphPaddingRight)
|
||||||
|
.attr("height", responsive.height)
|
||||||
|
.attr("class", `${styles.graphSVG}`);
|
||||||
|
|
||||||
|
if (selectionToolType === "brush") {
|
||||||
|
const brush = d3
|
||||||
|
.brush()
|
||||||
|
.extent([
|
||||||
|
[0, 0],
|
||||||
|
[responsive.width - graphPaddingRight, responsive.height]
|
||||||
|
])
|
||||||
|
.on("start", handleStartAction)
|
||||||
|
.on("brush", handleDragAction)
|
||||||
|
// FYI, brush doesn't generate cancel
|
||||||
|
.on("end", handleEndAction);
|
||||||
|
|
||||||
|
const brushContainer = svg
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "graph_brush")
|
||||||
|
.call(brush);
|
||||||
|
|
||||||
|
return { svg, container: brushContainer, tool: brush };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionToolType === "lasso") {
|
||||||
|
const lasso = Lasso()
|
||||||
|
.on("end", handleEndAction)
|
||||||
|
// FYI, Lasso doesn't generate drag
|
||||||
|
.on("start", handleStartAction)
|
||||||
|
.on("cancel", handleCancelAction);
|
||||||
|
|
||||||
|
const lassoContainer = svg.call(lasso);
|
||||||
|
|
||||||
|
return { svg, container: lassoContainer, tool: lasso };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("unknown graph selection tool");
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
import _ from "lodash";
|
||||||
|
import React from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import Categorical from "./categorical/categorical";
|
||||||
|
import Continuous from "./continuous/continuous";
|
||||||
|
import GeneExpression from "./geneExpression";
|
||||||
|
import * as globals from "../globals";
|
||||||
|
import DynamicScatterplot from "./scatterplot/scatterplot";
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
responsive: state.responsive,
|
||||||
|
datasetTitle: _.get(state.config, "displayNames.dataset"),
|
||||||
|
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
|
||||||
|
}))
|
||||||
|
class LeftSideBar extends React.Component {
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
responsive,
|
||||||
|
datasetTitle,
|
||||||
|
scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
/*
|
||||||
|
this magic number should be made less fragile,
|
||||||
|
if cellxgene logo or tabs change, this must as well
|
||||||
|
*/
|
||||||
|
const metadataSectionPadding = 0;
|
||||||
|
// scatterplotXXaccessor && scatterplotYYaccessor ? 450 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
backgroundColor: "white",
|
||||||
|
/* x y blur spread color */
|
||||||
|
boxShadow: "1px 0px 6px 2px rgba(153,153,153,0.4)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
data-testid="header"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: globals.cellxgeneTitleTopPadding,
|
||||||
|
left: globals.leftSidebarWidth + globals.cellxgeneTitleLeftPadding,
|
||||||
|
margin: 0,
|
||||||
|
fontSize: globals.largestFontSize,
|
||||||
|
color: globals.darkerGrey,
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
cellxgene: {datasetTitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: responsive.height - metadataSectionPadding,
|
||||||
|
width: globals.leftSidebarWidth,
|
||||||
|
overflowY: "auto",
|
||||||
|
overflowX: "hidden"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Categorical />
|
||||||
|
<GeneExpression />
|
||||||
|
<Continuous />
|
||||||
|
</div>
|
||||||
|
{scatterplotXXaccessor && scatterplotYYaccessor ? (
|
||||||
|
<DynamicScatterplot />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeftSideBar;
|
||||||
+1
-9
@@ -38,15 +38,7 @@ export default function(regl) {
|
|||||||
uniforms: {
|
uniforms: {
|
||||||
distance: regl.prop("distance"),
|
distance: regl.prop("distance"),
|
||||||
view: regl.prop("view"),
|
view: regl.prop("view"),
|
||||||
projection: (context, props) => {
|
projection: () => mat4.perspective([], Math.PI / 2, 1, 0.01, 1000)
|
||||||
return mat4.perspective(
|
|
||||||
[],
|
|
||||||
Math.PI / 2,
|
|
||||||
context.viewportWidth * props.scale / context.viewportHeight,
|
|
||||||
0.01,
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
count: regl.prop("count"),
|
count: regl.prop("count"),
|
||||||
+4
-5
@@ -1,15 +1,14 @@
|
|||||||
|
:local(.scatterplot) {
|
||||||
.scatterplot {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scatterplot svg,
|
:local(.scatterplot) svg,
|
||||||
.scatterplot canvas {
|
:local(.scatterplot) canvas {
|
||||||
font: 10px sans-serif;
|
font: 10px sans-serif;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scatterplot canvas {
|
:local(.scatterplot) canvas {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
// https://bl.ocks.org/Jverma/076377dd0125b1a508621441752735fc
|
||||||
|
// https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { Button, ButtonGroup } from "@blueprintjs/core";
|
||||||
|
import _regl from "regl";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
|
import _camera from "../../util/camera";
|
||||||
|
|
||||||
|
import setupScatterplot from "./setupScatterplot";
|
||||||
|
import styles from "./scatterplot.css";
|
||||||
|
|
||||||
|
import _drawPoints from "./drawPointsRegl";
|
||||||
|
import scaleLinear from "../../util/scaleLinear";
|
||||||
|
|
||||||
|
import { margin, width, height } from "./util";
|
||||||
|
import finiteExtent from "../../util/finiteExtent";
|
||||||
|
|
||||||
|
@connect(state => {
|
||||||
|
const { world, crossfilter } = state;
|
||||||
|
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
|
||||||
|
const expressionX =
|
||||||
|
world &&
|
||||||
|
scatterplotXXaccessor &&
|
||||||
|
world.varData.hasCol(scatterplotXXaccessor)
|
||||||
|
? world.varData.col(scatterplotXXaccessor).asArray()
|
||||||
|
: null;
|
||||||
|
const expressionY =
|
||||||
|
world &&
|
||||||
|
scatterplotYYaccessor &&
|
||||||
|
world.varData.hasCol(scatterplotYYaccessor)
|
||||||
|
? world.varData.col(scatterplotYYaccessor).asArray()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
world,
|
||||||
|
|
||||||
|
colorRGB: state.colors.rgb,
|
||||||
|
colorScale: state.colors.scale,
|
||||||
|
colorAccessor: state.colors.colorAccessor,
|
||||||
|
|
||||||
|
// Accessors are var/gene names (strings)
|
||||||
|
scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor,
|
||||||
|
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
|
||||||
|
|
||||||
|
differential: state.differential,
|
||||||
|
|
||||||
|
expressionX,
|
||||||
|
expressionY,
|
||||||
|
|
||||||
|
crossfilter
|
||||||
|
};
|
||||||
|
})
|
||||||
|
class Scatterplot extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.count = 0;
|
||||||
|
this.axes = false;
|
||||||
|
this.renderCache = {
|
||||||
|
positions: null,
|
||||||
|
colors: null,
|
||||||
|
sizes: null,
|
||||||
|
xScale: null,
|
||||||
|
yScale: null
|
||||||
|
};
|
||||||
|
this.state = {
|
||||||
|
svg: null,
|
||||||
|
minimized: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { svg } = setupScatterplot(width, height, margin);
|
||||||
|
let scales;
|
||||||
|
const { expressionX, expressionY } = this.props;
|
||||||
|
|
||||||
|
if (svg && expressionX && expressionY) {
|
||||||
|
scales = Scatterplot.setupScales(expressionX, expressionY);
|
||||||
|
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
|
||||||
|
this.renderCache = { ...this.renderCache, ...scales };
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
|
||||||
|
const regl = _regl(this.reglCanvas);
|
||||||
|
|
||||||
|
const drawPoints = _drawPoints(regl);
|
||||||
|
|
||||||
|
// preallocate buffers
|
||||||
|
const pointBuffer = regl.buffer();
|
||||||
|
const colorBuffer = regl.buffer();
|
||||||
|
const sizeBuffer = regl.buffer();
|
||||||
|
|
||||||
|
const reglRender = regl.frame(() => {
|
||||||
|
this.reglDraw(
|
||||||
|
regl,
|
||||||
|
drawPoints,
|
||||||
|
sizeBuffer,
|
||||||
|
colorBuffer,
|
||||||
|
pointBuffer,
|
||||||
|
camera
|
||||||
|
);
|
||||||
|
camera.tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.reglRenderState = "rendering";
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
regl,
|
||||||
|
sizeBuffer,
|
||||||
|
pointBuffer,
|
||||||
|
colorBuffer,
|
||||||
|
svg,
|
||||||
|
reglRender,
|
||||||
|
camera,
|
||||||
|
drawPoints
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const {
|
||||||
|
world,
|
||||||
|
crossfilter,
|
||||||
|
scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor,
|
||||||
|
expressionX,
|
||||||
|
expressionY,
|
||||||
|
colorRGB
|
||||||
|
} = this.props;
|
||||||
|
const {
|
||||||
|
reglRender,
|
||||||
|
regl,
|
||||||
|
pointBuffer,
|
||||||
|
colorBuffer,
|
||||||
|
sizeBuffer,
|
||||||
|
svg,
|
||||||
|
drawPoints,
|
||||||
|
camera
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
if (
|
||||||
|
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
|
||||||
|
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
|
||||||
|
world !== prevProps.world // shape or clip of world changed
|
||||||
|
) {
|
||||||
|
const scales = Scatterplot.setupScales(expressionX, expressionY);
|
||||||
|
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
|
||||||
|
this.renderCache = { ...this.renderCache, ...scales };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reglRender && this.reglRenderState === "rendering") {
|
||||||
|
reglRender.cancel();
|
||||||
|
this.reglRenderState = "paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
world &&
|
||||||
|
regl &&
|
||||||
|
pointBuffer &&
|
||||||
|
colorBuffer &&
|
||||||
|
sizeBuffer &&
|
||||||
|
expressionX &&
|
||||||
|
expressionY &&
|
||||||
|
scatterplotXXaccessor &&
|
||||||
|
scatterplotYYaccessor
|
||||||
|
) {
|
||||||
|
const { renderCache } = this;
|
||||||
|
const { xScale, yScale } = this.renderCache;
|
||||||
|
const cellCount = expressionX.length;
|
||||||
|
|
||||||
|
// Points change when expressionX or expressionY change.
|
||||||
|
if (
|
||||||
|
!renderCache.positions ||
|
||||||
|
expressionX !== prevProps.expressionX ||
|
||||||
|
expressionY !== prevProps.expressionY
|
||||||
|
) {
|
||||||
|
if (!renderCache.positions) {
|
||||||
|
renderCache.positions = new Float32Array(2 * cellCount);
|
||||||
|
}
|
||||||
|
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
|
||||||
|
const glScaleY = scaleLinear([0, height], [-1, 1]);
|
||||||
|
for (let i = 0, { positions } = renderCache; i < cellCount; i += 1) {
|
||||||
|
positions[2 * i] = glScaleX(xScale(expressionX[i]));
|
||||||
|
positions[2 * i + 1] = glScaleY(yScale(expressionY[i]));
|
||||||
|
}
|
||||||
|
pointBuffer({ data: renderCache.positions, dimension: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colors for each point - change only when props.colorsRGB change.
|
||||||
|
if (!renderCache.colors || colorRGB !== prevProps.colorRGB) {
|
||||||
|
if (!renderCache.colors) {
|
||||||
|
renderCache.colors = new Float32Array(3 * cellCount);
|
||||||
|
}
|
||||||
|
for (let i = 0, { colors } = renderCache; i < cellCount; i += 1) {
|
||||||
|
colors.set(colorRGB[i], 3 * i);
|
||||||
|
}
|
||||||
|
colorBuffer({ data: renderCache.colors, dimension: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sizes for each point - updates are triggered only when selected
|
||||||
|
// obs change
|
||||||
|
if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) {
|
||||||
|
if (!renderCache.sizes) {
|
||||||
|
renderCache.sizes = new Float32Array(cellCount);
|
||||||
|
}
|
||||||
|
crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2);
|
||||||
|
sizeBuffer({ data: renderCache.sizes, dimension: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.count = cellCount;
|
||||||
|
|
||||||
|
regl._refresh();
|
||||||
|
this.reglDraw(
|
||||||
|
regl,
|
||||||
|
drawPoints,
|
||||||
|
sizeBuffer,
|
||||||
|
colorBuffer,
|
||||||
|
pointBuffer,
|
||||||
|
camera
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static setupScales(expressionX, expressionY) {
|
||||||
|
const xScale = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain(finiteExtent(expressionX))
|
||||||
|
.range([0, width]);
|
||||||
|
const yScale = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain(finiteExtent(expressionY))
|
||||||
|
.range([height, 0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
xScale,
|
||||||
|
yScale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) {
|
||||||
|
regl.clear({
|
||||||
|
depth: 1,
|
||||||
|
color: [1, 1, 1, 1]
|
||||||
|
});
|
||||||
|
|
||||||
|
drawPoints({
|
||||||
|
size: sizeBuffer,
|
||||||
|
distance: camera.distance,
|
||||||
|
color: colorBuffer,
|
||||||
|
position: pointBuffer,
|
||||||
|
count: this.count,
|
||||||
|
view: camera.view()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawAxesSVG(xScale, yScale, svg) {
|
||||||
|
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
// the axes are much cleaner and easier now. No need to rotate and orient
|
||||||
|
// the axis, just call axisBottom, axisLeft etc.
|
||||||
|
const xAxis = d3
|
||||||
|
.axisBottom()
|
||||||
|
.ticks(7)
|
||||||
|
.scale(xScale);
|
||||||
|
|
||||||
|
const yAxis = d3
|
||||||
|
.axisLeft()
|
||||||
|
.ticks(7)
|
||||||
|
.scale(yScale);
|
||||||
|
|
||||||
|
// adding axes is also simpler now, just translate x-axis to (0,height)
|
||||||
|
// and it's alread defined to be a bottom axis.
|
||||||
|
svg
|
||||||
|
.append("g")
|
||||||
|
.attr("transform", `translate(0,${height})`)
|
||||||
|
.attr("class", "x axis")
|
||||||
|
.call(xAxis);
|
||||||
|
|
||||||
|
// y-axis is translated to (0,0)
|
||||||
|
svg
|
||||||
|
.append("g")
|
||||||
|
.attr("transform", "translate(0,0)")
|
||||||
|
.attr("class", "y axis")
|
||||||
|
.call(yAxis);
|
||||||
|
|
||||||
|
// adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10).
|
||||||
|
svg
|
||||||
|
.append("text")
|
||||||
|
.attr("x", 10)
|
||||||
|
.attr("y", 10)
|
||||||
|
.attr("class", "label")
|
||||||
|
.style("font-style", "italic")
|
||||||
|
.text(scatterplotYYaccessor);
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append("text")
|
||||||
|
.attr("x", width)
|
||||||
|
.attr("y", height - 10)
|
||||||
|
.attr("text-anchor", "end")
|
||||||
|
.attr("class", "label")
|
||||||
|
.style("font-style", "italic")
|
||||||
|
.text(scatterplotXXaccessor);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
const { minimized } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: minimized ? -height + -margin.top : 0,
|
||||||
|
borderRadius: "3px 3px 0px 0px",
|
||||||
|
left: globals.leftSidebarWidth + globals.scatterplotMarginLeft,
|
||||||
|
padding: "0px 20px 20px 0px",
|
||||||
|
backgroundColor: "white",
|
||||||
|
/* x y blur spread color */
|
||||||
|
boxShadow: "0px 0px 6px 2px rgba(153,153,153,0.4)"
|
||||||
|
}}
|
||||||
|
id="scatterplot_wrapper"
|
||||||
|
>
|
||||||
|
<ButtonGroup
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 5,
|
||||||
|
top: 5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
minimal
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ minimized: !minimized });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{minimized ? "show scatterplot" : "hide"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
minimal
|
||||||
|
data-testid="clear-scatterplot"
|
||||||
|
onClick={() =>
|
||||||
|
dispatch({
|
||||||
|
type: "clear scatterplot"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
remove
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<div
|
||||||
|
className={styles.scatterplot}
|
||||||
|
id="scatterplot"
|
||||||
|
style={{
|
||||||
|
width: `${width + margin.left + margin.right}px`,
|
||||||
|
height: `${height + margin.top + margin.bottom}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
data-testid="scatterplot"
|
||||||
|
style={{
|
||||||
|
marginLeft: margin.left - 7,
|
||||||
|
marginTop: margin.top
|
||||||
|
}}
|
||||||
|
ref={canvas => {
|
||||||
|
this.reglCanvas = canvas;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Scatterplot;
|
||||||
+7
-4
@@ -5,15 +5,18 @@
|
|||||||
******************************************
|
******************************************
|
||||||
******************************************/
|
******************************************/
|
||||||
|
|
||||||
const setupScatterplot = (width, height, margin) => {
|
import * as d3 from "d3";
|
||||||
var container = d3.select("#scatterplot");
|
|
||||||
|
|
||||||
var svg = container
|
const setupScatterplot = (width, height, margin) => {
|
||||||
|
const container = d3.select("#scatterplot");
|
||||||
|
|
||||||
|
const svg = container
|
||||||
.append("svg")
|
.append("svg")
|
||||||
.attr("width", width + margin.left + margin.right)
|
.attr("width", width + margin.left + margin.right)
|
||||||
.attr("height", height + margin.top + margin.bottom)
|
.attr("height", height + margin.top + margin.bottom)
|
||||||
|
.attr("data-testid", "scatterplot-svg")
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
svg
|
svg
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
import _ from "lodash";
|
export const margin = { top: 40, right: 5, bottom: 20, left: 60 };
|
||||||
|
|
||||||
const paddingRight = 120;
|
|
||||||
const continuousChartWidth = 340;
|
|
||||||
|
|
||||||
export const margin = { top: 66, right: 110, bottom: 20, left: 60 };
|
|
||||||
export const width = 340;
|
export const width = 340;
|
||||||
export const height = 340 - margin.top - margin.bottom;
|
export const height = 340 - margin.top - margin.bottom;
|
||||||
export const innerHeight = height - 2;
|
export const innerHeight = height - 2;
|
||||||
@@ -1,36 +1,24 @@
|
|||||||
// jshint esversion: 6
|
import { Colors } from "@blueprintjs/core";
|
||||||
/* these will be either (preferably) specified or inferred */
|
|
||||||
export const categories = [
|
/* if a categorical metadata field has more options than this, truncate */
|
||||||
"Sample.type",
|
export const maxCategoricalOptionsToDisplay = 100;
|
||||||
"Selection",
|
|
||||||
"Location",
|
/*
|
||||||
"Sample.name",
|
these are default values for configuration the CLI may supply.
|
||||||
"Class",
|
See the REST API and CLI specs for more info.
|
||||||
"Neoplastic"
|
*/
|
||||||
];
|
export const configDefaults = {
|
||||||
export const continuous = [
|
features: {},
|
||||||
"Total_reads",
|
displayNames: {},
|
||||||
"Unique_reads",
|
parameters: {
|
||||||
"Unique_reads_percent",
|
"max-category-items": 1000
|
||||||
"ERCC_reads",
|
}
|
||||||
"Non_ERCC_reads",
|
};
|
||||||
"ERCC_to_non_ERCC",
|
|
||||||
"Genes_detected",
|
|
||||||
"Multimapping_reads_percent",
|
|
||||||
"Splice_sites_AT.AC",
|
|
||||||
"Splice_sites_Annotated",
|
|
||||||
"Splice_sites_GC.AG",
|
|
||||||
"Splice_sites_GT.AG",
|
|
||||||
"Splice_sites_non_canonical",
|
|
||||||
"Splice_sites_total",
|
|
||||||
"Unmapped_mismatch",
|
|
||||||
"Unmapped_other",
|
|
||||||
"Unmapped_short"
|
|
||||||
];
|
|
||||||
|
|
||||||
/* colors */
|
/* colors */
|
||||||
export const blue = "#4a90e2";
|
export const blue = Colors.BLUE3;
|
||||||
export const hcaBlue = "#1c7cc7";
|
export const linkBlue = Colors.BLUE5;
|
||||||
|
export const lightestGrey = "rgb(249,249,249)";
|
||||||
export const lighterGrey = "rgb(245,245,245)";
|
export const lighterGrey = "rgb(245,245,245)";
|
||||||
export const lightGrey = "rgb(211,211,211)";
|
export const lightGrey = "rgb(211,211,211)";
|
||||||
export const mediumGrey = "rgb(153,153,153)";
|
export const mediumGrey = "rgb(153,153,153)";
|
||||||
@@ -41,36 +29,49 @@ export const brightBlue = "#4a90e2";
|
|||||||
export const brightGreen = "#A2D729";
|
export const brightGreen = "#A2D729";
|
||||||
export const darkGreen = "#448C4D";
|
export const darkGreen = "#448C4D";
|
||||||
|
|
||||||
|
export const nonFiniteCellColor = lightGrey;
|
||||||
|
export const defaultCellColor = "rgb(0,0,0,1)";
|
||||||
|
|
||||||
|
/* typography constants */
|
||||||
|
|
||||||
export const tiniestFontSize = 12;
|
export const tiniestFontSize = 12;
|
||||||
|
export const largestFontSize = 24;
|
||||||
|
export const uppercaseLetterSpacing = "0.04em";
|
||||||
export const bolder = 700;
|
export const bolder = 700;
|
||||||
|
|
||||||
export let API = {
|
|
||||||
// prefix: "http://api.clustering.czi.technology/api/",
|
|
||||||
//prefix: "http://tabulamuris.cxg.czi.technology/api/",
|
|
||||||
prefix: "http://pbmc3k.cxg.czi.technology/api/",
|
|
||||||
// prefix: "http://pbmc33k.cxg.czi.technology/api/",
|
|
||||||
|
|
||||||
// prefix: "http://api-staging.clustering.czi.technology/api/",
|
|
||||||
version: "v0.1/"
|
|
||||||
};
|
|
||||||
|
|
||||||
if (window.CELLXGENE && window.CELLXGENE.API) API = window.CELLXGENE.API;
|
|
||||||
|
|
||||||
export let datasetTitle = "";
|
|
||||||
|
|
||||||
if (window.CELLXGENE && window.CELLXGENE.datasetTitle)
|
|
||||||
datasetTitle = window.CELLXGENE.datasetTitle;
|
|
||||||
|
|
||||||
export const accentFont = "Georgia,Times,Times New Roman,serif";
|
export const accentFont = "Georgia,Times,Times New Roman,serif";
|
||||||
export const maxParagraphWidth = 600;
|
export const maxParagraphWidth = 600;
|
||||||
|
|
||||||
|
/* layout styles constants */
|
||||||
|
|
||||||
|
export const cellxgeneTitleLeftPadding = 14;
|
||||||
|
export const cellxgeneTitleTopPadding = 7;
|
||||||
|
|
||||||
export const maxControlsWidth = 800;
|
export const maxControlsWidth = 800;
|
||||||
|
|
||||||
export const graphMargin = { top: 20, right: 10, bottom: 30, left: 40 };
|
export const graphMargin = { top: 20, right: 10, bottom: 30, left: 40 };
|
||||||
// export const graphWidth = 1440 /* window width */ - 410 /* sidebar */ - (15 + 15) /* left right padding */ /* but responsive */;
|
|
||||||
// export const graphHeight = 500;
|
|
||||||
export const graphWidth = 700;
|
export const graphWidth = 700;
|
||||||
export const graphHeight = 700;
|
export const graphHeight = 700;
|
||||||
|
export const scatterplotMarginLeft = 25;
|
||||||
|
|
||||||
|
export const leftSidebarWidth = 365;
|
||||||
|
export const leftSidebarSectionHeading = {
|
||||||
|
fontSize: 18,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: ".05em"
|
||||||
|
};
|
||||||
|
export const leftSidebarSectionPadding = 10;
|
||||||
|
|
||||||
|
let _API = {
|
||||||
|
// prefix: "http://api.clustering.czi.technology/api/",
|
||||||
|
// prefix: "http://tabulamuris.cxg.czi.technology/api/",
|
||||||
|
// prefix: "http://api-staging.clustering.czi.technology/api/",
|
||||||
|
prefix: "http://localhost:5005/api/",
|
||||||
|
version: "v0.2/"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.API;
|
||||||
|
export const API = _API;
|
||||||
|
|
||||||
export const ordinalColors = [
|
export const ordinalColors = [
|
||||||
"#0ac115",
|
"#0ac115",
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
>>>GLOBAL<<< CSS imports & definitions
|
||||||
|
|
||||||
|
Do NOT put anything in this file that is not global. We do not perform
|
||||||
|
namespace mangling on these definitions. See webpack config for specifics.
|
||||||
|
*/
|
||||||
|
@import "~@blueprintjs/core/lib/css/blueprint.css";
|
||||||
|
@import "~@blueprintjs/icons/lib/css/blueprint-icons.css";
|
||||||
|
@import "~@blueprintjs/select/lib/css/blueprint-select.css";
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
import { FocusStyleManager } from "@blueprintjs/core";
|
||||||
|
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
/* our code */
|
||||||
|
import App from "./components/app";
|
||||||
|
import store from "./reducers";
|
||||||
|
|
||||||
|
FocusStyleManager.onlyShowFocusOnTabs();
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>,
|
||||||
|
document.getElementById("root")
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export default function cascadeReducers(arg) {
|
||||||
|
/*
|
||||||
|
Combined a set of cascading reducers into a single reducer. Cascading
|
||||||
|
reducers are reducers which may rely on state computed by another reducer.
|
||||||
|
Therefore, they:
|
||||||
|
- must be composed in a particular order (currently, this is a simple
|
||||||
|
linear list of reducers, run in list order)
|
||||||
|
- must have access to partially updated "next state" so they can further
|
||||||
|
derive state.
|
||||||
|
|
||||||
|
Parameter is one of:
|
||||||
|
- a Map object
|
||||||
|
- an array of tuples, [ [key1, reducer1], [key2, reducer2], ... ]
|
||||||
|
Ie, cascadeReducers([ ["a", reduceA], ["b", reduceB] ])
|
||||||
|
|
||||||
|
Each reducer will be called with the sigature:
|
||||||
|
(prevState, action, sharedNextState, sharedPrevState) => newState
|
||||||
|
|
||||||
|
cascadeReducers will build a composite newState object, much
|
||||||
|
like combinedReducers. Additional semantics:
|
||||||
|
- reducers guaranteed to be called in order
|
||||||
|
- each reducer will receive shared objects
|
||||||
|
*/
|
||||||
|
const reducers = arg instanceof Map ? arg : new Map(arg);
|
||||||
|
const reducerKeys = [...reducers.keys()];
|
||||||
|
return (prevState, action) => {
|
||||||
|
const nextState = {};
|
||||||
|
let stateChange = false;
|
||||||
|
for (let i = 0, l = reducerKeys.length; i < l; i += 1) {
|
||||||
|
const key = reducerKeys[i];
|
||||||
|
const reducer = reducers.get(key);
|
||||||
|
const prevStateForKey = prevState ? prevState[key] : undefined;
|
||||||
|
const nextStateForKey = reducer(
|
||||||
|
prevStateForKey,
|
||||||
|
action,
|
||||||
|
nextState,
|
||||||
|
prevState
|
||||||
|
);
|
||||||
|
nextState[key] = nextStateForKey;
|
||||||
|
stateChange = stateChange || nextStateForKey !== prevStateForKey;
|
||||||
|
}
|
||||||
|
return stateChange ? nextState : prevState;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
import { ControlsHelpers } from "../util/stateManager";
|
||||||
|
import * as globals from "../globals";
|
||||||
|
|
||||||
|
function maxCategoryItems(state) {
|
||||||
|
return _.get(
|
||||||
|
state.config,
|
||||||
|
"parameters.max-category-items",
|
||||||
|
globals.configDefaults.parameters["max-category-items"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoricalSelection = (
|
||||||
|
state,
|
||||||
|
action,
|
||||||
|
nextSharedState,
|
||||||
|
prevSharedState
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "initial data load complete (universe exists)":
|
||||||
|
case "set World to current selection":
|
||||||
|
case "reset World to eq Universe":
|
||||||
|
case "set clip quantiles": {
|
||||||
|
const { world } = nextSharedState;
|
||||||
|
return ControlsHelpers.createCategoricalSelection(
|
||||||
|
maxCategoryItems(prevSharedState),
|
||||||
|
world
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "categorical metadata filter select": {
|
||||||
|
/*
|
||||||
|
Set the specific category in this field to false
|
||||||
|
*/
|
||||||
|
const newCategorySelected = Array.from(
|
||||||
|
state[action.metadataField].categorySelected
|
||||||
|
);
|
||||||
|
newCategorySelected[action.categoryIndex] = true;
|
||||||
|
const newCategoricalSelection = {
|
||||||
|
...state,
|
||||||
|
[action.metadataField]: {
|
||||||
|
...state[action.metadataField],
|
||||||
|
categorySelected: newCategorySelected
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return newCategoricalSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "categorical metadata filter deselect": {
|
||||||
|
/*
|
||||||
|
Set the specific category in this field to false
|
||||||
|
*/
|
||||||
|
const newCategorySelected = Array.from(
|
||||||
|
state[action.metadataField].categorySelected
|
||||||
|
);
|
||||||
|
newCategorySelected[action.categoryIndex] = false;
|
||||||
|
const newCategoricalSelection = {
|
||||||
|
...state,
|
||||||
|
[action.metadataField]: {
|
||||||
|
...state[action.metadataField],
|
||||||
|
categorySelected: newCategorySelected
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return newCategoricalSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "categorical metadata filter none of these": {
|
||||||
|
/*
|
||||||
|
set all categories in this field to false.
|
||||||
|
*/
|
||||||
|
const newCategoricalSelection = {
|
||||||
|
...state,
|
||||||
|
[action.metadataField]: {
|
||||||
|
...state[action.metadataField],
|
||||||
|
categorySelected: Array.from(
|
||||||
|
state[action.metadataField].categorySelected
|
||||||
|
).fill(false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return newCategoricalSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "categorical metadata filter all of these": {
|
||||||
|
/*
|
||||||
|
set all categories in this field to true.
|
||||||
|
*/
|
||||||
|
const newCategoricalSelection = {
|
||||||
|
...state,
|
||||||
|
[action.metadataField]: {
|
||||||
|
...state[action.metadataField],
|
||||||
|
categorySelected: Array.from(
|
||||||
|
state[action.metadataField].categorySelected
|
||||||
|
).fill(true)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return newCategoricalSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoricalSelection;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { createColors } from "../util/stateManager";
|
||||||
|
|
||||||
|
const ColorsReducer = (
|
||||||
|
state = {
|
||||||
|
colorMode: null,
|
||||||
|
colorAccessor: null,
|
||||||
|
rgb: null,
|
||||||
|
scale: null
|
||||||
|
},
|
||||||
|
action,
|
||||||
|
nextSharedState,
|
||||||
|
prevSharedState
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "initial data load complete (universe exists)":
|
||||||
|
case "reset World to eq Universe": {
|
||||||
|
const { world } = nextSharedState;
|
||||||
|
const colorMode = null;
|
||||||
|
const colorAccessor = null;
|
||||||
|
const { rgb, scale } = createColors(world, colorMode);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
colorAccessor,
|
||||||
|
colorMode,
|
||||||
|
rgb,
|
||||||
|
scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "set clip quantiles":
|
||||||
|
case "set World to current selection": {
|
||||||
|
const { colorMode, colorAccessor } = state;
|
||||||
|
const { world } = nextSharedState;
|
||||||
|
const { rgb, scale } = createColors(world, colorMode, colorAccessor);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
rgb,
|
||||||
|
scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reset colorscale": {
|
||||||
|
const { world } = prevSharedState;
|
||||||
|
const { rgb, scale } = createColors(world);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
colorMode: null,
|
||||||
|
colorAccessor: null,
|
||||||
|
rgb,
|
||||||
|
scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "color by categorical metadata":
|
||||||
|
case "color by continuous metadata": {
|
||||||
|
const { world } = prevSharedState;
|
||||||
|
|
||||||
|
/* toggle between this mode and reset */
|
||||||
|
const resetCurrent =
|
||||||
|
action.type === state.colorMode &&
|
||||||
|
action.colorAccessor === state.colorAccessor;
|
||||||
|
const colorMode = !resetCurrent ? action.type : null;
|
||||||
|
const colorAccessor = !resetCurrent ? action.colorAccessor : null;
|
||||||
|
|
||||||
|
const { rgb, scale } = createColors(world, colorMode, colorAccessor);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
colorMode,
|
||||||
|
colorAccessor,
|
||||||
|
rgb,
|
||||||
|
scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "color by expression": {
|
||||||
|
const { world } = prevSharedState;
|
||||||
|
|
||||||
|
/* toggle between this mode and reset */
|
||||||
|
const resetCurrent =
|
||||||
|
action.type === state.colorMode && action.gene === state.colorAccessor;
|
||||||
|
const colorMode = !resetCurrent ? action.type : null;
|
||||||
|
const colorAccessor = !resetCurrent ? action.gene : null;
|
||||||
|
|
||||||
|
const { rgb, scale } = createColors(world, colorMode, colorAccessor);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
colorMode,
|
||||||
|
colorAccessor,
|
||||||
|
rgb,
|
||||||
|
scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColorsReducer;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
const Config = (
|
||||||
|
state = {
|
||||||
|
displayNames: null,
|
||||||
|
features: null,
|
||||||
|
parameters: null
|
||||||
|
},
|
||||||
|
action
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "initial data load start":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: true,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
case "configuration load complete":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
...action.config
|
||||||
|
};
|
||||||
|
case "initial data load error":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
error: action.error
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Config;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { makeContinuousDimensionName } from "../util/nameCreators";
|
||||||
|
|
||||||
|
const ContinuousSelection = (state = {}, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "reset World to eq Universe":
|
||||||
|
case "set clip quantiles": {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "continuous metadata histogram start":
|
||||||
|
case "continuous metadata histogram brush":
|
||||||
|
case "continuous metadata histogram end": {
|
||||||
|
const name = makeContinuousDimensionName(
|
||||||
|
action.continuousNamespace,
|
||||||
|
action.selection
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[name]: action.range
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "continuous metadata histogram cancel": {
|
||||||
|
const name = makeContinuousDimensionName(
|
||||||
|
action.continuousNamespace,
|
||||||
|
action.selection
|
||||||
|
);
|
||||||
|
const { [name]: deletedField, ...newState } = state;
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContinuousSelection;
|
||||||
Vendored
+184
@@ -0,0 +1,184 @@
|
|||||||
|
// jshint esversion: 6
|
||||||
|
|
||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
import { WorldUtil } from "../util/stateManager";
|
||||||
|
|
||||||
|
const Controls = (
|
||||||
|
state = {
|
||||||
|
// data loading flag
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// all of the data + selection state
|
||||||
|
userDefinedGenes: [],
|
||||||
|
userDefinedGenesLoading: false,
|
||||||
|
diffexpGenes: [],
|
||||||
|
|
||||||
|
resettingInterface: false,
|
||||||
|
|
||||||
|
opacityForDeselectedCells: 0.2,
|
||||||
|
scatterplotXXaccessor: null, // just easier to read
|
||||||
|
scatterplotYYaccessor: null,
|
||||||
|
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
|
||||||
|
__storedStateForCelllist1__: null /* will need procedural control of brush ie., brush.extent https://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81 */,
|
||||||
|
__storedStateForCelllist2__: null
|
||||||
|
},
|
||||||
|
action,
|
||||||
|
nextSharedState,
|
||||||
|
prevSharedState
|
||||||
|
) => {
|
||||||
|
/*
|
||||||
|
For now, log anything looking like an error to the console.
|
||||||
|
*/
|
||||||
|
if (action.error || /error/i.test(action.type)) {
|
||||||
|
console.error(action.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
/*****************************************************
|
||||||
|
Initialization, World/Universe management
|
||||||
|
and data loading.
|
||||||
|
******************************************************/
|
||||||
|
case "initial data load start": {
|
||||||
|
return { ...state, loading: true };
|
||||||
|
}
|
||||||
|
case "initial data load complete (universe exists)": {
|
||||||
|
/* first light - create world & other data-driven defaults */
|
||||||
|
WorldUtil.clearCaches();
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
resettingInterface: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "reset World to eq Universe": {
|
||||||
|
WorldUtil.clearCaches();
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
resettingInterface: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "set World to current selection": {
|
||||||
|
WorldUtil.clearCaches();
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "request user defined gene started": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
userDefinedGenesLoading: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "request user defined gene error": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
userDefinedGenesLoading: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "request user defined gene success": {
|
||||||
|
const { userDefinedGenes } = state;
|
||||||
|
const _userDefinedGenes = _.uniq(
|
||||||
|
userDefinedGenes.concat(action.data.genes)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
userDefinedGenes: _userDefinedGenes,
|
||||||
|
userDefinedGenesLoading: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "request differential expression success": {
|
||||||
|
const { world } = prevSharedState;
|
||||||
|
const _diffexpGenes = [];
|
||||||
|
action.data.forEach(d => {
|
||||||
|
_diffexpGenes.push(world.varAnnotations.at(d[0], "name"));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
diffexpGenes: _diffexpGenes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "clear differential expression": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
diffexpGenes: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "clear user defined gene": {
|
||||||
|
const { userDefinedGenes } = state;
|
||||||
|
const newUserDefinedGenes = _.filter(
|
||||||
|
userDefinedGenes,
|
||||||
|
d => d !== action.data
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
userDefinedGenes: newUserDefinedGenes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "clear all user defined genes": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
userDefinedGenes: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "expression load error":
|
||||||
|
case "initial data load error": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: action.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************
|
||||||
|
User Events
|
||||||
|
*******************************/
|
||||||
|
case "change opacity deselected cells in 2d graph background":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
opacityForDeselectedCells: action.data
|
||||||
|
};
|
||||||
|
case "increment graph render counter": {
|
||||||
|
const c = state.graphRenderCounter + 1;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
graphRenderCounter: c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "interface reset started": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
resettingInterface: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************
|
||||||
|
Scatterplot
|
||||||
|
*******************************/
|
||||||
|
case "set scatterplot x":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
scatterplotXXaccessor: action.data
|
||||||
|
};
|
||||||
|
case "set scatterplot y":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
scatterplotYYaccessor: action.data
|
||||||
|
};
|
||||||
|
case "clear scatterplot":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
scatterplotXXaccessor: null,
|
||||||
|
scatterplotYYaccessor: null
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Controls;
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
import Crossfilter from "../util/typedCrossfilter";
|
||||||
|
import { World, ControlsHelpers } from "../util/stateManager";
|
||||||
|
import {
|
||||||
|
layoutDimensionName,
|
||||||
|
obsAnnoDimensionName,
|
||||||
|
userDefinedDimensionName,
|
||||||
|
diffexpDimensionName,
|
||||||
|
makeContinuousDimensionName
|
||||||
|
} from "../util/nameCreators";
|
||||||
|
|
||||||
|
const XYDimName = layoutDimensionName("XY");
|
||||||
|
|
||||||
|
const CrossfilterReducer = (
|
||||||
|
state = null,
|
||||||
|
action,
|
||||||
|
nextSharedState,
|
||||||
|
prevSharedState
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "initial data load complete (universe exists)": {
|
||||||
|
const { world } = nextSharedState;
|
||||||
|
const crossfilter = World.createObsDimensions(
|
||||||
|
new Crossfilter(world.obsAnnotations),
|
||||||
|
world
|
||||||
|
);
|
||||||
|
return crossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reset World to eq Universe": {
|
||||||
|
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
|
||||||
|
const { world } = nextSharedState;
|
||||||
|
const crossfilter = ControlsHelpers.createGeneDimensions(
|
||||||
|
userDefinedGenes,
|
||||||
|
diffexpGenes,
|
||||||
|
world,
|
||||||
|
prevSharedState.resetCache.crossfilter
|
||||||
|
);
|
||||||
|
return crossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "set clip quantiles":
|
||||||
|
case "set World to current selection": {
|
||||||
|
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
|
||||||
|
const { world } = nextSharedState;
|
||||||
|
let crossfilter = new Crossfilter(world.obsAnnotations);
|
||||||
|
crossfilter = World.createObsDimensions(crossfilter, world);
|
||||||
|
crossfilter = ControlsHelpers.createGeneDimensions(
|
||||||
|
userDefinedGenes,
|
||||||
|
diffexpGenes,
|
||||||
|
world,
|
||||||
|
crossfilter
|
||||||
|
);
|
||||||
|
return crossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "request user defined gene success": {
|
||||||
|
const { world } = prevSharedState;
|
||||||
|
const gene = action.data.genes[0];
|
||||||
|
return state.addDimension(
|
||||||
|
userDefinedDimensionName(gene),
|
||||||
|
"scalar",
|
||||||
|
world.varData.col(gene).asArray(),
|
||||||
|
Float32Array
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "request differential expression success": {
|
||||||
|
const { world } = prevSharedState;
|
||||||
|
const genes = _.map(action.data, d =>
|
||||||
|
world.varAnnotations.at(d[0], "name")
|
||||||
|
);
|
||||||
|
const crossfilter = _.reduce(
|
||||||
|
genes,
|
||||||
|
(xfltr, gene) =>
|
||||||
|
xfltr.addDimension(
|
||||||
|
diffexpDimensionName(gene),
|
||||||
|
"scalar",
|
||||||
|
world.varData.col(gene).asArray(),
|
||||||
|
Float32Array
|
||||||
|
),
|
||||||
|
state
|
||||||
|
);
|
||||||
|
return crossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "clear differential expression": {
|
||||||
|
const { world } = prevSharedState;
|
||||||
|
const crossfilter = _.reduce(
|
||||||
|
action.diffExp,
|
||||||
|
(xfltr, values) => {
|
||||||
|
const name = world.varAnnotations.at(values[0], "name");
|
||||||
|
return xfltr.delDimension(diffexpDimensionName(name));
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
return crossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "clear user defined gene": {
|
||||||
|
return state.delDimension(userDefinedDimensionName(action.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "clear all user defined genes": {
|
||||||
|
const { userDefinedGenes } = prevSharedState.controls;
|
||||||
|
const crossfilter = _.reduce(
|
||||||
|
userDefinedGenes,
|
||||||
|
(xfltr, gene) => xfltr.delDimension(userDefinedDimensionName(gene)),
|
||||||
|
state
|
||||||
|
);
|
||||||
|
return crossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "graph brush end":
|
||||||
|
case "graph brush change": {
|
||||||
|
const [minX, maxY] = action.brushCoords.northwest;
|
||||||
|
const [maxX, minY] = action.brushCoords.southeast;
|
||||||
|
return state.select(XYDimName, {
|
||||||
|
mode: "within-rect",
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
maxX,
|
||||||
|
maxY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "graph lasso end": {
|
||||||
|
const { polygon } = action;
|
||||||
|
return state.select(XYDimName, {
|
||||||
|
mode: "within-polygon",
|
||||||
|
polygon
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "graph lasso cancel":
|
||||||
|
case "graph brush cancel":
|
||||||
|
case "graph lasso deselect":
|
||||||
|
case "graph brush deselect": {
|
||||||
|
return state.select(XYDimName, { mode: "all" });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "continuous metadata histogram start":
|
||||||
|
case "continuous metadata histogram brush":
|
||||||
|
case "continuous metadata histogram cancel":
|
||||||
|
case "continuous metadata histogram end": {
|
||||||
|
const name = makeContinuousDimensionName(
|
||||||
|
action.continuousNamespace,
|
||||||
|
action.selection
|
||||||
|
);
|
||||||
|
// action.selection: metadata name being selected
|
||||||
|
// action.range: filter range, or null if deselected
|
||||||
|
if (!action.range) {
|
||||||
|
return state.select(name, { mode: "all" });
|
||||||
|
}
|
||||||
|
const [lo, hi] = action.range;
|
||||||
|
const newState = state.select(name, { mode: "range", lo, hi });
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "categorical metadata filter select":
|
||||||
|
case "categorical metadata filter deselect": {
|
||||||
|
const { categoricalSelection } = nextSharedState;
|
||||||
|
const cat = categoricalSelection[action.metadataField];
|
||||||
|
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||||
|
mode: "exact",
|
||||||
|
values: ControlsHelpers.selectedValuesForCategory(cat)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "categorical metadata filter none of these": {
|
||||||
|
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||||
|
mode: "none"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "categorical metadata filter all of these": {
|
||||||
|
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||||
|
mode: "all"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CrossfilterReducer;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
|
|
||||||
const Differential = (
|
const Differential = (
|
||||||
state = {
|
state = {
|
||||||
diffExp: null,
|
diffExp: null,
|
||||||
@@ -11,29 +12,49 @@ const Differential = (
|
|||||||
) => {
|
) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "request differential expression started":
|
case "request differential expression started":
|
||||||
return Object.assign({}, state, {
|
return {
|
||||||
|
...state,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null
|
error: null
|
||||||
});
|
};
|
||||||
case "request differential expression success":
|
case "request differential expression success":
|
||||||
return Object.assign({}, state, {
|
return {
|
||||||
|
...state,
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
diffExp: action.data
|
diffExp: action.data
|
||||||
});
|
};
|
||||||
case "request differential expression error":
|
case "request differential expression error":
|
||||||
return Object.assign({}, state, {
|
return {
|
||||||
|
...state,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: action.data
|
error: action.data
|
||||||
});
|
};
|
||||||
case "store current cell selection as differential set 1":
|
case "store current cell selection as differential set 1":
|
||||||
return Object.assign({}, state, {
|
return {
|
||||||
|
...state,
|
||||||
celllist1: action.data
|
celllist1: action.data
|
||||||
});
|
};
|
||||||
case "store current cell selection as differential set 2":
|
case "store current cell selection as differential set 2":
|
||||||
return Object.assign({}, state, {
|
return {
|
||||||
|
...state,
|
||||||
celllist2: action.data
|
celllist2: action.data
|
||||||
});
|
};
|
||||||
|
case "clear differential expression":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
diffExp: null,
|
||||||
|
celllist1: null,
|
||||||
|
celllist2: null
|
||||||
|
};
|
||||||
|
case "reset World to eq Universe":
|
||||||
|
case "set World to current selection":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
diffExp: null,
|
||||||
|
celllist1: null,
|
||||||
|
celllist2: null
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const GraphSelection = (
|
||||||
|
state = {
|
||||||
|
tool: "lasso", // what selection tool mode (lasso, brush, ...)
|
||||||
|
selection: { mode: "all" } // current selection, which is tool specific
|
||||||
|
},
|
||||||
|
action
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "set clip quantiles":
|
||||||
|
case "reset World to eq Universe": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selection: {
|
||||||
|
mode: "all"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "graph brush end":
|
||||||
|
case "graph brush change": {
|
||||||
|
const { brushCoords } = action;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selection: {
|
||||||
|
mode: "within-rect",
|
||||||
|
brushCoords
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "graph lasso end": {
|
||||||
|
const { polygon } = action;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selection: {
|
||||||
|
mode: "within-polygon",
|
||||||
|
polygon
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "graph lasso cancel":
|
||||||
|
case "graph brush cancel":
|
||||||
|
case "graph lasso deselect":
|
||||||
|
case "graph brush deselect": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selection: {
|
||||||
|
mode: "all"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GraphSelection;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { createStore, applyMiddleware } from "redux";
|
||||||
|
import thunk from "redux-thunk";
|
||||||
|
|
||||||
|
import cascadeReducers from "./cascade";
|
||||||
|
import undoable from "./undoable";
|
||||||
|
import config from "./config";
|
||||||
|
import universe from "./universe";
|
||||||
|
import world from "./world";
|
||||||
|
import categoricalSelection from "./categoricalSelection";
|
||||||
|
import continuousSelection from "./continuousSelection";
|
||||||
|
import graphSelection from "./graphSelection";
|
||||||
|
import crossfilter from "./crossfilter";
|
||||||
|
import colors from "./colors";
|
||||||
|
import differential from "./differential";
|
||||||
|
import responsive from "./responsive";
|
||||||
|
import controls from "./controls";
|
||||||
|
import resetCache from "./resetCache";
|
||||||
|
|
||||||
|
import undoableConfig from "./undoableConfig";
|
||||||
|
|
||||||
|
const Reducer = undoable(
|
||||||
|
cascadeReducers([
|
||||||
|
["config", config],
|
||||||
|
["universe", universe],
|
||||||
|
["world", world],
|
||||||
|
["categoricalSelection", categoricalSelection],
|
||||||
|
["continuousSelection", continuousSelection],
|
||||||
|
["graphSelection", graphSelection],
|
||||||
|
["crossfilter", crossfilter],
|
||||||
|
["colors", colors],
|
||||||
|
["controls", controls],
|
||||||
|
["differential", differential],
|
||||||
|
["responsive", responsive],
|
||||||
|
["resetCache", resetCache]
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
"world",
|
||||||
|
"categoricalSelection",
|
||||||
|
"continuousSelection",
|
||||||
|
"graphSelection",
|
||||||
|
"crossfilter",
|
||||||
|
"colors",
|
||||||
|
"controls",
|
||||||
|
"differential"
|
||||||
|
],
|
||||||
|
undoableConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = createStore(Reducer, applyMiddleware(thunk));
|
||||||
|
|
||||||
|
export default store;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
Reducer which caches derived state to be used in a reset or other
|
||||||
|
recomputation.
|
||||||
|
|
||||||
|
Currently this only caches the baseline (full universe) world & crossfilter,
|
||||||
|
for use in a Reset.
|
||||||
|
*/
|
||||||
|
const ResetCacheReducer = (
|
||||||
|
state = {
|
||||||
|
world: null,
|
||||||
|
crossfilter: null
|
||||||
|
},
|
||||||
|
action,
|
||||||
|
nextSharedState
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "initial data load complete (universe exists)": {
|
||||||
|
const { world, crossfilter } = nextSharedState;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
world,
|
||||||
|
crossfilter
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetCacheReducer;
|
||||||
@@ -8,10 +8,11 @@ const Responsive = (
|
|||||||
) => {
|
) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "window resize":
|
case "window resize":
|
||||||
return Object.assign({}, state, {
|
return {
|
||||||
|
...state,
|
||||||
width: action.data.width,
|
width: action.data.width,
|
||||||
height: action.data.height
|
height: action.data.height
|
||||||
});
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
/*
|
||||||
|
A redo/undo meta reducer for Redux. Designed to work well with the cascadeReducer().
|
||||||
|
|
||||||
|
Requires three parameters:
|
||||||
|
* reducer - a reducer, which MUST return an object as state.
|
||||||
|
* undoableKeys - an array of object keys (strings). If any of these keys
|
||||||
|
are in the object/state returned by the reducer, they will be treated as
|
||||||
|
state to be made "undoable".
|
||||||
|
* options - an optional object, which may contain the following parameters:
|
||||||
|
* historyLimit: max number of historical states to remember (aka max undo depth)
|
||||||
|
* actionFilter: filter function, (state, action, filterState) => value.
|
||||||
|
See below for details.
|
||||||
|
* debug: if truish, will print helpful log messages about history manipulation
|
||||||
|
|
||||||
|
This meta reducer accepts three actions types:
|
||||||
|
* @@undoable/undo - move back in history
|
||||||
|
* @@undoable/redo - move forward in history
|
||||||
|
* @@undoable/clear - clear history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Action filter - controls the undoable reducer side-effects. If not
|
||||||
|
specified, the action filter defaults to "save", ie, pushes a redo
|
||||||
|
point upon each action.
|
||||||
|
|
||||||
|
The action filter callback has access to the current action, the entire
|
||||||
|
undoable reducer state, and any state it wants to manage ("filterState").
|
||||||
|
This filter state will be passed to each action filter call, and any
|
||||||
|
value returned (via @@undoable/filterState field described below) will be
|
||||||
|
MERGED into the current filter state.
|
||||||
|
|
||||||
|
An object must be returned (the "undoable action"), indicating desired
|
||||||
|
history state processing. The undoable action object contents, by key:
|
||||||
|
|
||||||
|
@@undoable/filterAction: required. Can be one of:
|
||||||
|
"skip" - reduce the current action, but no other side effects.
|
||||||
|
Same as returning false.
|
||||||
|
"clear" - reduce the current action, and clear history state.
|
||||||
|
"save" - push the previous state onto the history stack (ie,
|
||||||
|
before reducing the action)
|
||||||
|
"stashPending" - reduce action, save state as pending. Does not
|
||||||
|
not commit it to history. Along with cancelPending and applyPending,
|
||||||
|
can be used to delay commit of history (eg, for multi-action
|
||||||
|
groupings, asynch operations, etc).
|
||||||
|
"cancelPending" - reduce action, cancel any pending state save.
|
||||||
|
"applyPending" - commit any pending state to the history stack,
|
||||||
|
then reduce action.
|
||||||
|
|
||||||
|
@@undoable/filterState: optional. If this value is set, it will be
|
||||||
|
MERGED into the current filter state. The value and semantics of any
|
||||||
|
filter state are entirely at the discretion of the action filter.
|
||||||
|
|
||||||
|
*/
|
||||||
|
import fromEntries from "../util/fromEntries";
|
||||||
|
|
||||||
|
const historyKeyPrefix = "@@undoable/";
|
||||||
|
const pastKey = `${historyKeyPrefix}past`;
|
||||||
|
const futureKey = `${historyKeyPrefix}future`;
|
||||||
|
const filterStateKey = `${historyKeyPrefix}filterState`;
|
||||||
|
const filterActionKey = `${historyKeyPrefix}filterAction`;
|
||||||
|
const pendingKey = `${historyKeyPrefix}pending`;
|
||||||
|
const defaultHistoryLimit = -100;
|
||||||
|
|
||||||
|
const Undoable = (reducer, undoableKeys, options = {}) => {
|
||||||
|
const { debug } = options;
|
||||||
|
let { historyLimit } = options;
|
||||||
|
if (!historyLimit) historyLimit = defaultHistoryLimit;
|
||||||
|
if (historyLimit > 0) historyLimit = -historyLimit;
|
||||||
|
const actionFilter =
|
||||||
|
options.actionFilter || (() => ({ [filterActionKey]: "save" }));
|
||||||
|
|
||||||
|
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
|
||||||
|
throw new Error("undoable keys array must be specified");
|
||||||
|
const undoableKeysSet = new Set(undoableKeys);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Undo the current to previous history
|
||||||
|
*/
|
||||||
|
function undo(currentState) {
|
||||||
|
const past = currentState[pastKey];
|
||||||
|
const future = currentState[futureKey];
|
||||||
|
if (past.length === 0) return currentState;
|
||||||
|
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||||
|
undoableKeysSet.has(kv[0])
|
||||||
|
);
|
||||||
|
const newPast = [...past];
|
||||||
|
const newState = newPast.pop();
|
||||||
|
const newFuture = push(future, currentUndoableState);
|
||||||
|
const nextState = {
|
||||||
|
...currentState,
|
||||||
|
...fromEntries(newState),
|
||||||
|
[pastKey]: newPast,
|
||||||
|
[futureKey]: newFuture,
|
||||||
|
[pendingKey]: null
|
||||||
|
};
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Replay future, previously undone.
|
||||||
|
*/
|
||||||
|
function redo(currentState) {
|
||||||
|
const past = currentState[pastKey] || [];
|
||||||
|
const future = currentState[futureKey] || [];
|
||||||
|
if (future.length === 0) return currentState;
|
||||||
|
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||||
|
undoableKeysSet.has(kv[0])
|
||||||
|
);
|
||||||
|
const newFuture = [...future];
|
||||||
|
const newState = newFuture.pop();
|
||||||
|
const newPast = push(past, currentUndoableState);
|
||||||
|
const nextState = {
|
||||||
|
...currentState,
|
||||||
|
...fromEntries(newState),
|
||||||
|
[pastKey]: newPast,
|
||||||
|
[futureKey]: newFuture,
|
||||||
|
[pendingKey]: null
|
||||||
|
};
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Clear the history state. No side-effects on current state.
|
||||||
|
*/
|
||||||
|
function clear(currentState) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
[pastKey]: [],
|
||||||
|
[futureKey]: [],
|
||||||
|
[filterStateKey]: {},
|
||||||
|
[pendingKey]: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reduce current action, with no history side-effects
|
||||||
|
*/
|
||||||
|
function skip(currentState, action, filterState) {
|
||||||
|
const past = currentState[pastKey] || [];
|
||||||
|
const pending = currentState[pendingKey];
|
||||||
|
const res = reducer(currentState, action);
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
[pastKey]: past,
|
||||||
|
[futureKey]: [],
|
||||||
|
[filterStateKey]: filterState,
|
||||||
|
[pendingKey]: pending
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Save current state in the history, then reduce action.
|
||||||
|
*/
|
||||||
|
function save(currentState, action, filterState) {
|
||||||
|
const past = currentState[pastKey] || [];
|
||||||
|
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||||
|
undoableKeysSet.has(kv[0])
|
||||||
|
);
|
||||||
|
const res = reducer(currentState, action);
|
||||||
|
const newPast = push(past, currentUndoableState, historyLimit);
|
||||||
|
const nextState = {
|
||||||
|
...res,
|
||||||
|
[pastKey]: newPast,
|
||||||
|
[futureKey]: [],
|
||||||
|
[filterStateKey]: filterState,
|
||||||
|
[pendingKey]: null
|
||||||
|
};
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Save current state as pending history change. No other side effects.
|
||||||
|
*/
|
||||||
|
function stashPending(currentState) {
|
||||||
|
const currentUndoableState = Object.entries(currentState).filter(kv =>
|
||||||
|
undoableKeysSet.has(kv[0])
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
[pendingKey]: currentUndoableState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Cancel pending history state change. No other side effects.
|
||||||
|
*/
|
||||||
|
function cancelPending(currentState) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
[pendingKey]: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Push pending state onto the history stack
|
||||||
|
*/
|
||||||
|
function applyPending(currentState) {
|
||||||
|
const past = currentState[pastKey] || [];
|
||||||
|
const pendingState = currentState[pendingKey];
|
||||||
|
const newPast = push(past, pendingState, historyLimit);
|
||||||
|
const nextState = {
|
||||||
|
...currentState,
|
||||||
|
[pastKey]: newPast,
|
||||||
|
[futureKey]: [],
|
||||||
|
[pendingKey]: null
|
||||||
|
};
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
currentState = {
|
||||||
|
[pastKey]: [],
|
||||||
|
[futureKey]: [],
|
||||||
|
[filterStateKey]: {},
|
||||||
|
[pendingKey]: null
|
||||||
|
},
|
||||||
|
action
|
||||||
|
) => {
|
||||||
|
const aType = action.type;
|
||||||
|
switch (aType) {
|
||||||
|
case "@@undoable/undo": {
|
||||||
|
return undo(currentState, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "@@undoable/redo": {
|
||||||
|
return redo(currentState, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "@@undoable/clear": {
|
||||||
|
return clear(currentState, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const currentFilterState = currentState[filterStateKey];
|
||||||
|
const actionFilterResp = actionFilter(
|
||||||
|
currentState,
|
||||||
|
action,
|
||||||
|
currentFilterState
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
[filterActionKey]: filterAction,
|
||||||
|
[filterStateKey]: filterStateUpdate
|
||||||
|
} = actionFilterResp;
|
||||||
|
const nextFilterState = { ...currentFilterState, ...filterStateUpdate };
|
||||||
|
|
||||||
|
switch (filterAction) {
|
||||||
|
case "clear":
|
||||||
|
if (debug) console.log("---- CLEAR HISTO", action.type);
|
||||||
|
return clear(skip(currentState, action, nextFilterState));
|
||||||
|
|
||||||
|
case "save":
|
||||||
|
if (debug) console.log("---- SAVE HISTO", action.type);
|
||||||
|
return save(currentState, action, nextFilterState);
|
||||||
|
|
||||||
|
case "stashPending":
|
||||||
|
if (debug) console.log("---- STASH PENDING", action.type);
|
||||||
|
return skip(stashPending(currentState), action, nextFilterState);
|
||||||
|
|
||||||
|
case "cancelPending":
|
||||||
|
if (debug) console.log("---- CANCEL PENDING", action.type);
|
||||||
|
return skip(cancelPending(currentState), action, nextFilterState);
|
||||||
|
|
||||||
|
case "applyPending":
|
||||||
|
if (debug) console.log("---- APPLY PENDING", action.type);
|
||||||
|
return skip(applyPending(currentState), action, nextFilterState);
|
||||||
|
|
||||||
|
case "skip":
|
||||||
|
default:
|
||||||
|
return skip(currentState, action, nextFilterState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function push(arr, val, limit = undefined) {
|
||||||
|
/*
|
||||||
|
functional array push, with a max length limit to the new array.
|
||||||
|
Like Array.push, except it returns new array and discards as needed
|
||||||
|
to enforce the length limit.
|
||||||
|
*/
|
||||||
|
const narr = arr.slice(limit);
|
||||||
|
narr.push(val);
|
||||||
|
return narr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Undoable;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import StateMachine from "../util/statemachine";
|
||||||
|
import createFsmTransitions from "./undoableFsm";
|
||||||
|
|
||||||
|
const actionKey = "@@undoable/filterAction";
|
||||||
|
const stateKey = "@@undoable/filterState";
|
||||||
|
|
||||||
|
/*
|
||||||
|
these actions will not affect history
|
||||||
|
*/
|
||||||
|
const skipOnActions = new Set([
|
||||||
|
"url changed",
|
||||||
|
"interface reset started",
|
||||||
|
"initial data load start",
|
||||||
|
"configuration load complete",
|
||||||
|
"increment graph render counter",
|
||||||
|
"window resize",
|
||||||
|
"user reset start",
|
||||||
|
"reset colorscale",
|
||||||
|
|
||||||
|
"graph brush change",
|
||||||
|
"continuous metadata histogram brush",
|
||||||
|
|
||||||
|
"expression load start",
|
||||||
|
"expression load success",
|
||||||
|
"expression load error",
|
||||||
|
|
||||||
|
"request user defined gene started",
|
||||||
|
"request user defined gene success",
|
||||||
|
"clear all user defined genes",
|
||||||
|
|
||||||
|
"get single gene expression for coloring started",
|
||||||
|
"get single gene expression for coloring error"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
identical, repeated occurances of these action types will be debounced.
|
||||||
|
Entire action must be identical (all keys).
|
||||||
|
*/
|
||||||
|
const debounceOnActions = new Set([
|
||||||
|
"color by categorical metadata",
|
||||||
|
"color by continuous metadata",
|
||||||
|
"color by expression"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
history will be cleared when these actions occur
|
||||||
|
*/
|
||||||
|
const clearOnActions = new Set([
|
||||||
|
"initial data load complete (universe exists)",
|
||||||
|
"reset World to eq Universe",
|
||||||
|
"initial data load error",
|
||||||
|
"user reset end"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
An immediate history save will be done for these
|
||||||
|
*/
|
||||||
|
const saveOnActions = new Set([
|
||||||
|
"categorical metadata filter select",
|
||||||
|
"categorical metadata filter deselect",
|
||||||
|
"categorical metadata filter all of these",
|
||||||
|
"categorical metadata none of these",
|
||||||
|
|
||||||
|
"color by categorical metadata",
|
||||||
|
"color by continuous metadata",
|
||||||
|
"color by expression",
|
||||||
|
|
||||||
|
"set scatterplot x",
|
||||||
|
"set scatterplot y",
|
||||||
|
|
||||||
|
"store current cell selection as differential set 1",
|
||||||
|
"store current cell selection as differential set 2",
|
||||||
|
|
||||||
|
"set World to current selection",
|
||||||
|
"set clip quantiles"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
StateMachine - processing complex action handling - see FSM graph for
|
||||||
|
actual structure, in undoableFsm.js
|
||||||
|
**/
|
||||||
|
|
||||||
|
/*
|
||||||
|
Default FSM actions. Used to side-effect transitions in the graph.
|
||||||
|
See graph definition for the transitions that use each.
|
||||||
|
|
||||||
|
Signature: (fsm, transition, reducerState, reducerAction) => undoableAction
|
||||||
|
*/
|
||||||
|
const stashPending = fsm => ({
|
||||||
|
[actionKey]: "stashPending",
|
||||||
|
[stateKey]: { fsm }
|
||||||
|
});
|
||||||
|
const cancelPending = () => ({
|
||||||
|
[actionKey]: "cancelPending",
|
||||||
|
[stateKey]: { fsm: null }
|
||||||
|
});
|
||||||
|
const applyPending = () => ({
|
||||||
|
[actionKey]: "applyPending",
|
||||||
|
[stateKey]: { fsm: null }
|
||||||
|
});
|
||||||
|
const skip = fsm => ({ [actionKey]: "skip", [stateKey]: { fsm } });
|
||||||
|
const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } });
|
||||||
|
const save = fsm => ({ [actionKey]: "save", [stateKey]: { fsm } });
|
||||||
|
|
||||||
|
/*
|
||||||
|
Error handler for state transitions that are unexpected. Called by
|
||||||
|
StateMachine when it doesn't know what to do.
|
||||||
|
|
||||||
|
Signature: (fsm, event, from) => undoableAction
|
||||||
|
*/
|
||||||
|
const onFsmError = (fsm, event, from) => {
|
||||||
|
console.error(`FSM error [event: "${event}", state: "${from}"]`, fsm);
|
||||||
|
// In production, try to recover gracefully if we have unexpected state
|
||||||
|
return clear(fsm);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Definition of the transition graph mapping action types to history side effects.
|
||||||
|
*/
|
||||||
|
const fsmTransitions = createFsmTransitions(
|
||||||
|
stashPending,
|
||||||
|
cancelPending,
|
||||||
|
applyPending,
|
||||||
|
skip,
|
||||||
|
clear,
|
||||||
|
save
|
||||||
|
);
|
||||||
|
/* State machine we clone whenever we need to run it */
|
||||||
|
const seedFsm = new StateMachine("init", fsmTransitions, onFsmError);
|
||||||
|
|
||||||
|
/*
|
||||||
|
See undoable.js for description action filter interface description.
|
||||||
|
|
||||||
|
Basic approach:
|
||||||
|
* trivial handlers for skip, clear & save cases to keep config simple.
|
||||||
|
* only implement complex state machines where absolutely required (eg,
|
||||||
|
multi-event seleciton and the like)
|
||||||
|
*/
|
||||||
|
const actionFilter = debug => (state, action, prevFilterState) => {
|
||||||
|
const actionType = action.type;
|
||||||
|
const filterState = {
|
||||||
|
...prevFilterState,
|
||||||
|
prevAction: action
|
||||||
|
};
|
||||||
|
if (skipOnActions.has(actionType)) {
|
||||||
|
return { [actionKey]: "skip", [stateKey]: filterState };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
debounceOnActions.has(actionType) &&
|
||||||
|
shallowObjectEq(action, prevFilterState.prevAction)
|
||||||
|
) {
|
||||||
|
return { [actionKey]: "skip", [stateKey]: filterState };
|
||||||
|
}
|
||||||
|
if (clearOnActions.has(actionType)) {
|
||||||
|
return { [actionKey]: "clear", [stateKey]: filterState };
|
||||||
|
}
|
||||||
|
if (saveOnActions.has(actionType)) {
|
||||||
|
return { [actionKey]: "save", [stateKey]: filterState };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Else, something more complex OR unknown to us....
|
||||||
|
*/
|
||||||
|
if (seedFsm.events.has(actionType)) {
|
||||||
|
let { fsm } = filterState;
|
||||||
|
if (!fsm) {
|
||||||
|
/* no active FSM, so create one in init state */
|
||||||
|
fsm = seedFsm.clone("init");
|
||||||
|
}
|
||||||
|
return fsm.next(action.type, { state, action });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* else, we have no idea what this is - skip it */
|
||||||
|
if (debug) console.log("**** ACTION FILTER EVENT HANDLER MISS", actionType);
|
||||||
|
return { [actionKey]: "skip", [stateKey]: filterState };
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
return true if objA and objB are ===, OR if:
|
||||||
|
- are both objects and not null
|
||||||
|
- have same own properties
|
||||||
|
- all values are strict equal (===)
|
||||||
|
*/
|
||||||
|
function shallowObjectEq(objA, objB) {
|
||||||
|
if (objA === objB) return true;
|
||||||
|
if (!objA || !objB) return false;
|
||||||
|
if (!shallowArrayEq(Object.keys(objA), Object.keys(objB))) return false;
|
||||||
|
if (!shallowArrayEq(Object.values(objA), Object.values(objB))) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
return true if arrA and arrB contain the same strict-equal values,
|
||||||
|
in the same order.
|
||||||
|
*/
|
||||||
|
function shallowArrayEq(arrA, arrB) {
|
||||||
|
if (arrA.length !== arrB.length) return false;
|
||||||
|
for (let i = 0, l = arrA.length; i < l; i += 1) {
|
||||||
|
if (arrA[i] !== arrB[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* configuration for the undoable meta reducer */
|
||||||
|
const debug = false; // set truish for undoble debugging
|
||||||
|
const undoableConfig = {
|
||||||
|
debug,
|
||||||
|
historyLimit: 50, // maximum history size
|
||||||
|
actionFilter: actionFilter(debug)
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
this code is strictly for sanity checking configuration, and is only
|
||||||
|
enabled when we are debugging the undoable configuration (ie, debug === true).
|
||||||
|
*/
|
||||||
|
if (debug) {
|
||||||
|
/*
|
||||||
|
Confirm no intersection between the various trivial rejection action filters
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
new Set([...skipOnActions].filter(x => clearOnActions.has(x))).size > 0 ||
|
||||||
|
new Set([...skipOnActions].filter(x => saveOnActions.has(x))).size > 0 ||
|
||||||
|
new Set([...clearOnActions].filter(x => saveOnActions.has(x))).size > 0
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"Undoable misconfiguration - action filters have redundant events"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Confirm that no FSM events are blocked by a trivial rejection filter.
|
||||||
|
If this occurs, the FSM can't ever see the events needed to process
|
||||||
|
state transitions.
|
||||||
|
*/
|
||||||
|
const trivialFilters = new Set([
|
||||||
|
...skipOnActions,
|
||||||
|
...clearOnActions,
|
||||||
|
...saveOnActions
|
||||||
|
]);
|
||||||
|
const trivialOverlapWithFsm = new Set(
|
||||||
|
[...trivialFilters].filter(x => seedFsm.events.has(x))
|
||||||
|
);
|
||||||
|
if (trivialOverlapWithFsm.size > 0) {
|
||||||
|
console.error(
|
||||||
|
"Undoable misconfiguration - trivival action filter blocking FSM filter",
|
||||||
|
[...trivialOverlapWithFsm]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default undoableConfig;
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
State transition graph for complex action/history interactions.
|
||||||
|
|
||||||
|
Assumed configuration from undoableConfig:
|
||||||
|
* By convention, "init" is used as the start state for all, and "done"
|
||||||
|
as the final state.
|
||||||
|
* Unexpected states will result in an error, plus a clear and cancelPending
|
||||||
|
side-effect.
|
||||||
|
|
||||||
|
TODO: is is possible there is a more concise format for this, as it is
|
||||||
|
a fairly repetitive pattern.
|
||||||
|
|
||||||
|
These events are largely one of two types:
|
||||||
|
a) async operations or multi-event options that should only be committed
|
||||||
|
upon some success criteria, otherwise cancelled.
|
||||||
|
|
||||||
|
b) compound actions that should be collapsed into a single history change.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
const createFsmTransitions = (
|
||||||
|
stashPending,
|
||||||
|
cancelPending,
|
||||||
|
applyPending,
|
||||||
|
skip,
|
||||||
|
clear,
|
||||||
|
save
|
||||||
|
) => {
|
||||||
|
return [
|
||||||
|
/* graph selection brushing */
|
||||||
|
{
|
||||||
|
event: "graph brush start",
|
||||||
|
from: "init",
|
||||||
|
to: "graph brush in progress",
|
||||||
|
action: stashPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "graph brush cancel",
|
||||||
|
from: "graph brush in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "graph brush deselect",
|
||||||
|
from: "graph brush in progress",
|
||||||
|
to: "done",
|
||||||
|
/* if current selection is all, cancelPending. Else, applyPending */
|
||||||
|
action: (fsm, transition, data) =>
|
||||||
|
data.state.graphSelection.selection.mode === "all"
|
||||||
|
? cancelPending()
|
||||||
|
: applyPending()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "graph brush end",
|
||||||
|
from: "graph brush in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
|
||||||
|
/* graph selection lasso */
|
||||||
|
{
|
||||||
|
event: "graph lasso start",
|
||||||
|
from: "init",
|
||||||
|
to: "graph lasso in progress",
|
||||||
|
action: stashPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "graph lasso cancel",
|
||||||
|
from: "graph lasso in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "graph lasso deselect",
|
||||||
|
from: "graph lasso in progress",
|
||||||
|
to: "done",
|
||||||
|
/* if current selection is all, cancelPending. Else, applyPending */
|
||||||
|
action: (fsm, transition, data) =>
|
||||||
|
data.state.graphSelection.selection.mode === "all"
|
||||||
|
? cancelPending()
|
||||||
|
: applyPending()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "graph lasso end",
|
||||||
|
from: "graph lasso in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Continuous metadata histogram brush selection */
|
||||||
|
{
|
||||||
|
event: "continuous metadata histogram start",
|
||||||
|
from: "init",
|
||||||
|
to: "continuous histo select in progress",
|
||||||
|
action: stashPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "continuous metadata histogram cancel",
|
||||||
|
from: "continuous histo select in progress",
|
||||||
|
to: "done",
|
||||||
|
action: cancelPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "continuous metadata histogram end",
|
||||||
|
from: "continuous histo select in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Single gene request by user */
|
||||||
|
{
|
||||||
|
event: "single user defined gene start",
|
||||||
|
from: "init",
|
||||||
|
to: "single user gene request in progress",
|
||||||
|
action: stashPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "request user defined gene error",
|
||||||
|
from: "single user gene request in progress",
|
||||||
|
to: "single user gene error in progress",
|
||||||
|
action: skip
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "single user defined gene error",
|
||||||
|
from: "single user gene error in progress",
|
||||||
|
to: "done",
|
||||||
|
action: cancelPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "single user defined gene complete",
|
||||||
|
from: "single user gene request in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Bulk gene request by user */
|
||||||
|
{
|
||||||
|
event: "bulk user defined gene start",
|
||||||
|
from: "init",
|
||||||
|
to: "bulk user gene request in progress",
|
||||||
|
action: stashPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "request user defined gene error",
|
||||||
|
from: "bulk user gene request in progress",
|
||||||
|
to: "bulk user gene request error in progress",
|
||||||
|
action: skip
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "bulk user defined gene error",
|
||||||
|
from: "bulk user gene request error in progress",
|
||||||
|
to: "done",
|
||||||
|
action: cancelPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "bulk user defined gene complete",
|
||||||
|
from: "bulk user gene request in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Compute Differential Expression button user action */
|
||||||
|
{
|
||||||
|
event: "request differential expression started",
|
||||||
|
from: "init",
|
||||||
|
to: "diffexp in progress",
|
||||||
|
action: stashPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "request user defined gene error",
|
||||||
|
from: "diffexp in progress",
|
||||||
|
to: "done",
|
||||||
|
action: cancelPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "request differential expression success",
|
||||||
|
from: "diffexp in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Clear Differential Expression button user action */
|
||||||
|
{
|
||||||
|
event: "clear differential expression",
|
||||||
|
from: "init",
|
||||||
|
to: "CDE Button in progress",
|
||||||
|
action: stashPending
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "clear scatterplot",
|
||||||
|
from: "CDE Button in progress",
|
||||||
|
to: "done",
|
||||||
|
action: applyPending
|
||||||
|
},
|
||||||
|
|
||||||
|
/* clear scatter plot button (eg, on scatterplot view) */
|
||||||
|
{
|
||||||
|
event: "clear scatterplot",
|
||||||
|
from: "init",
|
||||||
|
to: "done",
|
||||||
|
action: save
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createFsmTransitions;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ControlsHelpers } from "../util/stateManager";
|
||||||
|
|
||||||
|
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "initial data load complete (universe exists)": {
|
||||||
|
const { universe } = action;
|
||||||
|
return universe;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "expression load success": {
|
||||||
|
let { varData } = state;
|
||||||
|
|
||||||
|
// Lazy load new expression data into the varData dataframe, if
|
||||||
|
// not already present.
|
||||||
|
//
|
||||||
|
Object.entries(action.expressionData).forEach(([key, val]) => {
|
||||||
|
// If not already in universe.varData, save entire expression column
|
||||||
|
if (!varData.hasCol(key)) {
|
||||||
|
varData = varData.withCol(key, val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prune size of varData "cache" if getting out of hand....
|
||||||
|
//
|
||||||
|
const { userDefinedGenes, diffexpGenes } = prevSharedState;
|
||||||
|
const allTheGenesWeNeed = [
|
||||||
|
...new Set(
|
||||||
|
userDefinedGenes,
|
||||||
|
diffexpGenes,
|
||||||
|
Object.keys(action.expressionData)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
varData
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Universe;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { World, ControlsHelpers } from "../util/stateManager";
|
||||||
|
import clip from "../util/clip";
|
||||||
|
import quantile from "../util/quantile";
|
||||||
|
|
||||||
|
const WorldReducer = (
|
||||||
|
state = null,
|
||||||
|
action,
|
||||||
|
nextSharedState,
|
||||||
|
prevSharedState
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "initial data load complete (universe exists)": {
|
||||||
|
const { universe } = nextSharedState;
|
||||||
|
const world = World.createWorldFromEntireUniverse(universe);
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reset World to eq Universe": {
|
||||||
|
return prevSharedState.resetCache.world;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "set World to current selection": {
|
||||||
|
/* Set viewable world to be the currently selected data */
|
||||||
|
const world = World.createWorldBySelection(
|
||||||
|
action.universe,
|
||||||
|
action.world,
|
||||||
|
action.crossfilter
|
||||||
|
);
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "set clip quantiles": {
|
||||||
|
const world = World.createWorldWithNewClip(
|
||||||
|
prevSharedState.universe,
|
||||||
|
state,
|
||||||
|
prevSharedState.crossfilter,
|
||||||
|
action.clipQuantiles
|
||||||
|
);
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "expression load success": {
|
||||||
|
const { universe } = nextSharedState;
|
||||||
|
const universeVarData = universe.varData;
|
||||||
|
let unclippedVarData = state.unclipped.varData;
|
||||||
|
|
||||||
|
// Lazy load new expression data into the unclipped varData dataframe, if
|
||||||
|
// not already present.
|
||||||
|
//
|
||||||
|
Object.entries(action.expressionData).forEach(([key, val]) => {
|
||||||
|
// If not already in world.varData, save sliced expression column
|
||||||
|
if (!unclippedVarData.hasCol(key)) {
|
||||||
|
// Slice if world !== universe, else just use whole column.
|
||||||
|
// Use the obsAnnotation index as the cut key, as we keep
|
||||||
|
// all world dataframes in sync.
|
||||||
|
let worldValSlice = val;
|
||||||
|
if (!World.worldEqUniverse(state, universe)) {
|
||||||
|
worldValSlice = universeVarData
|
||||||
|
.subset(state.obsAnnotations.rowIndex.keys(), [key], null)
|
||||||
|
.icol(0)
|
||||||
|
.asArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now build world's varData dataframe
|
||||||
|
unclippedVarData = unclippedVarData.withCol(
|
||||||
|
key,
|
||||||
|
worldValSlice,
|
||||||
|
state.obsAnnotations.rowIndex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prune size of varData unclipped dataframe if getting out of hand....
|
||||||
|
//
|
||||||
|
const { userDefinedGenes, diffexpGenes } = prevSharedState;
|
||||||
|
const allTheGenesWeNeed = [
|
||||||
|
...new Set(
|
||||||
|
userDefinedGenes,
|
||||||
|
diffexpGenes,
|
||||||
|
Object.keys(action.expressionData)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
unclippedVarData = ControlsHelpers.pruneVarDataCache(
|
||||||
|
unclippedVarData,
|
||||||
|
allTheGenesWeNeed
|
||||||
|
);
|
||||||
|
|
||||||
|
// at this point, we have the unclipped data in unclippedVarData.
|
||||||
|
// Now create clipped.
|
||||||
|
// - Drop columns no longer needed
|
||||||
|
// - Add new columns
|
||||||
|
//
|
||||||
|
let clippedVarData = state.varData;
|
||||||
|
const keysToDrop = clippedVarData.colIndex
|
||||||
|
.keys()
|
||||||
|
.filter(k => !unclippedVarData.hasCol(k));
|
||||||
|
const keysToAdd = unclippedVarData.colIndex
|
||||||
|
.keys()
|
||||||
|
.filter(k => !clippedVarData.hasCol(k));
|
||||||
|
keysToDrop.forEach(k => {
|
||||||
|
clippedVarData = clippedVarData.dropCol(k);
|
||||||
|
});
|
||||||
|
keysToAdd.forEach(k => {
|
||||||
|
const data = unclippedVarData.col(k).asArray();
|
||||||
|
const q = [state.clipQuantiles.min, state.clipQuantiles.max];
|
||||||
|
const [qMinVal, qMaxVal] = quantile(q, data);
|
||||||
|
const clippedData = clip(data, qMinVal, qMaxVal, Number.NaN);
|
||||||
|
clippedVarData = clippedVarData.withCol(
|
||||||
|
k,
|
||||||
|
clippedData,
|
||||||
|
state.obsAnnotations.rowIndex
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
varData: clippedVarData,
|
||||||
|
unclipped: {
|
||||||
|
...state.unclipped,
|
||||||
|
varData: unclippedVarData
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorldReducer;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
/* XXX: cough, cough, ... */
|
||||||
|
import { postNetworkErrorToast } from "../components/framework/toasters";
|
||||||
|
|
||||||
|
/*
|
||||||
|
dispatch an action error to the user. Currently we use
|
||||||
|
async toasts.
|
||||||
|
*/
|
||||||
|
export const dispatchNetworkErrorMessageToUser = message =>
|
||||||
|
postNetworkErrorToast(message);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Catch unexpected errors and make sure we don't lose them!
|
||||||
|
*/
|
||||||
|
export function catchErrorsWrap(fn, dispatchToUser = false) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
fn(dispatch, getState).catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
if (dispatchToUser) {
|
||||||
|
dispatchNetworkErrorMessageToUser(error.message);
|
||||||
|
}
|
||||||
|
dispatch({ type: "UNEXPECTED ERROR", error });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Wrapper to perform async fetch with some modest error handling
|
||||||
|
and decoding.
|
||||||
|
*/
|
||||||
|
const doFetch = async (url, acceptType) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "get",
|
||||||
|
headers: new Headers({
|
||||||
|
Accept: acceptType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok && res.headers.get("Content-Type").includes(acceptType)) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// else an error
|
||||||
|
let msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
|
||||||
|
const body = await res.text();
|
||||||
|
if (body && body.length > 0) {
|
||||||
|
msg = `${msg} -- ${body}`;
|
||||||
|
}
|
||||||
|
dispatchNetworkErrorMessageToUser(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Wrapper to perform an async fetch and JSON decode response.
|
||||||
|
*/
|
||||||
|
export const doJsonRequest = async url => {
|
||||||
|
const res = await doFetch(url, "application/json");
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Wrapper to perform an async fetch for binary data.
|
||||||
|
*/
|
||||||
|
export const doBinaryRequest = async url => {
|
||||||
|
const res = await doFetch(url, "application/octet-stream");
|
||||||
|
return res.arrayBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
This function "packs" filter index lists into the more efficient
|
||||||
|
"range" form specified in the REST 0.2 spec.
|
||||||
|
|
||||||
|
Specifically, it turns an array of indices [0, 1, 2, 10, 11, 14, ...]
|
||||||
|
into a form that encodes runs of consecutive numbers as [min, max].
|
||||||
|
Array may not be sorted, but will only contain uniq values.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
indices - input array of numbers (index)
|
||||||
|
minRangeLength - hint, min range length before it is encoded into range format.
|
||||||
|
sorted - boolean hint indicating array is presorted, ascending order
|
||||||
|
|
||||||
|
So [1, 2, 3, 4, 10, 11, 14] -> [ [1, 4], [10, 11], 14]
|
||||||
|
*/
|
||||||
|
export const rangeEncodeIndices = (
|
||||||
|
indices,
|
||||||
|
minRangeLength = 3,
|
||||||
|
sorted = false
|
||||||
|
) => {
|
||||||
|
if (indices.length === 0) {
|
||||||
|
return indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sorted) {
|
||||||
|
indices = _.sortBy(indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Array(indices.length);
|
||||||
|
let resultTail = 0;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < indices.length) {
|
||||||
|
const begin = indices[i];
|
||||||
|
let current;
|
||||||
|
do {
|
||||||
|
current = indices[i];
|
||||||
|
i += 1;
|
||||||
|
} while (i < indices.length && indices[i] === current + 1);
|
||||||
|
|
||||||
|
if (current - begin + 1 >= minRangeLength) {
|
||||||
|
result[resultTail] = [begin, current];
|
||||||
|
resultTail += 1;
|
||||||
|
} else {
|
||||||
|
for (let j = begin; j <= current; j += 1, resultTail += 1) {
|
||||||
|
result[resultTail] = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.length = resultTail;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
var createCamera = require("orbit-camera");
|
const createCamera = require("orbit-camera");
|
||||||
var createScroll = require("scroll-speed");
|
const createScroll = require("scroll-speed");
|
||||||
var mp = require("mouse-position");
|
const mp = require("mouse-position");
|
||||||
var mb = require("mouse-pressed");
|
const mb = require("mouse-pressed");
|
||||||
var key = require("key-pressed");
|
const key = require("key-pressed");
|
||||||
|
|
||||||
const panSpeed = 0.4;
|
const panSpeed = 0.4;
|
||||||
const scaleSpeed = 0.5;
|
const scaleSpeed = 0.5;
|
||||||
@@ -17,20 +17,19 @@ function attachCamera(canvas, opts) {
|
|||||||
opts.scale = opts.scale !== false;
|
opts.scale = opts.scale !== false;
|
||||||
opts.rotate = opts.rotate !== false;
|
opts.rotate = opts.rotate !== false;
|
||||||
|
|
||||||
var scroll = createScroll(canvas, opts.scale);
|
const scroll = createScroll(canvas, opts.scale);
|
||||||
var mbut = mb(canvas, opts.rotate);
|
const mbut = mb(canvas, opts.rotate);
|
||||||
var mpos = mp(canvas);
|
const mpos = mp(canvas);
|
||||||
var camera = createCamera([0, 0, 1], [0, 0, -1], [0, 1, 0]);
|
const camera = createCamera([0, 0, 1], [0, 0, -1], [0, 1, 0]);
|
||||||
|
|
||||||
camera.tick = tick;
|
camera.tick = tick;
|
||||||
|
|
||||||
return camera;
|
return camera;
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
var ctrl = key("<control>") || key("<alt>");
|
const ctrl = key("<control>") || key("<alt>");
|
||||||
var alt = key("<shift>");
|
const alt = key("<shift>");
|
||||||
var height = canvas.height;
|
const { height, width } = canvas;
|
||||||
var width = canvas.width;
|
|
||||||
|
|
||||||
if (opts.rotate && mbut.left && ctrl && !alt) {
|
if (opts.rotate && mbut.left && ctrl && !alt) {
|
||||||
camera.rotate(
|
camera.rotate(
|
||||||
@@ -41,23 +40,17 @@ function attachCamera(canvas, opts) {
|
|||||||
|
|
||||||
if ((opts.pan && mbut.right) || (mbut.left && !ctrl && !alt)) {
|
if ((opts.pan && mbut.right) || (mbut.left && !ctrl && !alt)) {
|
||||||
camera.pan([
|
camera.pan([
|
||||||
panSpeed *
|
((panSpeed * (mpos[0] - mpos.prev[0])) / width) * camera.distance,
|
||||||
(mpos[0] - mpos.prev[0]) /
|
((panSpeed * (mpos[1] - mpos.prev[1])) / height) * camera.distance
|
||||||
width *
|
|
||||||
Math.pow(camera.distance, 1),
|
|
||||||
panSpeed *
|
|
||||||
(mpos[1] - mpos.prev[1]) /
|
|
||||||
height *
|
|
||||||
Math.pow(camera.distance, 1)
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.scale && scroll[1]) {
|
if (opts.scale && scroll[1]) {
|
||||||
camera.distance *= Math.exp(scroll[1] * scaleSpeed / height);
|
camera.distance *= Math.exp((scroll[1] * scaleSpeed) / height);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.scale && (mbut.middle || (mbut.left && !ctrl && alt))) {
|
if (opts.scale && (mbut.middle || (mbut.left && !ctrl && alt))) {
|
||||||
var d = mpos.y - mpos.prevY;
|
const d = mpos.y - mpos.prevY;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
|
|
||||||
camera.distance *= Math.exp(d / height);
|
camera.distance *= Math.exp(d / height);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
clip - clip all values in a Array or TypedArray, IN PLACE.
|
||||||
|
|
||||||
|
Values in array are clipped if less than `lower` or greater than `upper`.
|
||||||
|
|
||||||
|
If `setTo` is undefined, values less than `lower` will be set to `lower`,
|
||||||
|
and values greater than `upper` will be set to `upper`.
|
||||||
|
|
||||||
|
If `setTo` is not undefined, values outside the [lower, upper] range will be set to
|
||||||
|
`setTo`.
|
||||||
|
|
||||||
|
*/
|
||||||
|
export default function clip(arr, lower, upper, setTo) {
|
||||||
|
const lowerSet = setTo === undefined ? lower : setTo;
|
||||||
|
const upperSet = setTo === undefined ? upper : setTo;
|
||||||
|
for (let i = 0, l = arr.length; i < l; i += 1) {
|
||||||
|
const v = arr[i];
|
||||||
|
if (v < lower) {
|
||||||
|
arr[i] = lowerSet;
|
||||||
|
} else if (v > upper) {
|
||||||
|
arr[i] = upperSet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
import { IdentityInt32Index, isLabelIndex } from "./labelIndex";
|
||||||
|
// weird cross-dependency that we should clean up someday...
|
||||||
|
import { sortArray } from "../typedCrossfilter/sort";
|
||||||
|
import { isTypedArray, isArrayOrTypedArray, callOnceLazy } from "./util";
|
||||||
|
import { summarizeContinuous, summarizeCategorical } from "./summarize";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Dataframe is an immutable 2D matrix similiar to Python Pandas Dataframe,
|
||||||
|
but (currently) without all of the surrounding support functions.
|
||||||
|
Data is stored in column-major layout, and each column is monomorphic.
|
||||||
|
|
||||||
|
It supports:
|
||||||
|
* Relatively efficient creation, cloning and subsetting
|
||||||
|
* Very efficient columnar access (eg, sum down a column), and access
|
||||||
|
to the underlying column arrays.
|
||||||
|
* Data access by row/col offset or label. Labels are reasonably well
|
||||||
|
optimized for both numeric lables and arbitrary (eg, sting) labels.
|
||||||
|
|
||||||
|
It does not currently support:
|
||||||
|
* Views on matrix subset - for currently known access patterns,
|
||||||
|
it is more effiicent to copy on subsetting, optimizing for access
|
||||||
|
speed over memory use.
|
||||||
|
* JS iterators - they are too slow. Use explicit iteration over
|
||||||
|
offest or labels.
|
||||||
|
|
||||||
|
Important assumptions embedded in the API:
|
||||||
|
* Columns are implicitly categorical if they are a JS Array and numeric
|
||||||
|
(aka continuous) if they are a TypedArray.
|
||||||
|
|
||||||
|
There are three index types for row/col indexing:
|
||||||
|
* IdentityInt32Index - noop index, where the index label is the offset.
|
||||||
|
* KeyIndex - index arbitrary JS objects.
|
||||||
|
* DenseInt32Index - integer indexing. Optimization over KeyIndex as it uses
|
||||||
|
Int32Array as a back-map to offsets. This means that the index array
|
||||||
|
must be sized to [minLabel, maxLabel), so this is only useful when the label
|
||||||
|
range is relatively close the underlying offset range [minOffset, maxOffset).
|
||||||
|
|
||||||
|
All private functions/methods/fields are prefixed by '__', eg, __compile().
|
||||||
|
Don't use them outside of this file.
|
||||||
|
|
||||||
|
Simple example:
|
||||||
|
|
||||||
|
// default indexing is integer offset.
|
||||||
|
const df = Dataframe.create([2,2], [['a', 'b'], [0, 1]])
|
||||||
|
console.log(df.at(0,0)); // outputs: a
|
||||||
|
console.log(df.col(1).asArray()); // outputs: [0, 1]
|
||||||
|
|
||||||
|
// KeyIndex
|
||||||
|
const df = new Dataframe([1,2], [['a'], ['b']], null, new KeyIndex(['A', 'B']))
|
||||||
|
console.log(df.at(0, 'A')); // outputs: a
|
||||||
|
console.log(df.col('A').asArray(); // outputs: ['a']
|
||||||
|
|
||||||
|
Performance tuning is primarily focused on columnar access patterns, which is the
|
||||||
|
dominant pattern in cellxgene.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
Dataframe
|
||||||
|
**/
|
||||||
|
|
||||||
|
class Dataframe {
|
||||||
|
/**
|
||||||
|
Constructors & factories
|
||||||
|
**/
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
dims,
|
||||||
|
columnarData,
|
||||||
|
rowIndex = null,
|
||||||
|
colIndex = null,
|
||||||
|
__columnsAccessor = [] // private interface
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
The base constructor is relatively hard to use - as an alternative,
|
||||||
|
see factory methods and clone/slice, below.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
* dims - 2D array describing intendend dimensionality: [nRows,nCols].
|
||||||
|
* columnarData - JS array, nCols in length, containing array
|
||||||
|
or TypedArray of length nRows.
|
||||||
|
* rowIndex/colIndex - null (create default index using offsets as key),
|
||||||
|
or a caller-provided index.
|
||||||
|
* __columnsAccessor - private interface, do not specify. Used internally
|
||||||
|
to improve caching of column accessors when possible (eg, clone(),
|
||||||
|
dropCol(), withCol()).
|
||||||
|
All columns and indices must have appropriate dimensionality.
|
||||||
|
*/
|
||||||
|
const [nRows, nCols] = dims;
|
||||||
|
if (nRows < 0 || nCols < 0) {
|
||||||
|
throw new RangeError("Dataframe dimensions must be positive");
|
||||||
|
}
|
||||||
|
if (!rowIndex) {
|
||||||
|
rowIndex = new IdentityInt32Index(nRows);
|
||||||
|
}
|
||||||
|
if (!colIndex) {
|
||||||
|
colIndex = new IdentityInt32Index(nCols);
|
||||||
|
}
|
||||||
|
Dataframe.__errorChecks(dims, columnarData, rowIndex, colIndex);
|
||||||
|
|
||||||
|
this.__columns = Array.from(columnarData);
|
||||||
|
this.dims = dims;
|
||||||
|
this.length = nRows; // convenience accessor for row dimension
|
||||||
|
this.rowIndex = rowIndex;
|
||||||
|
this.colIndex = colIndex;
|
||||||
|
|
||||||
|
this.__compile(__columnsAccessor);
|
||||||
|
}
|
||||||
|
|
||||||
|
static __errorChecks(dims, columnarData, rowIndex, colIndex) {
|
||||||
|
const [nRows, nCols] = dims;
|
||||||
|
|
||||||
|
/* check for expected types */
|
||||||
|
if (!Array.isArray(columnarData)) {
|
||||||
|
throw new TypeError("Dataframe constructor requires array of columns");
|
||||||
|
}
|
||||||
|
if (!columnarData.every(c => isArrayOrTypedArray(c))) {
|
||||||
|
throw new TypeError("Dataframe columns must all be Array or TypedArray");
|
||||||
|
}
|
||||||
|
if (!isLabelIndex(rowIndex)) {
|
||||||
|
throw new TypeError("Dataframe rowIndex is an unsupported type.");
|
||||||
|
}
|
||||||
|
if (!isLabelIndex(colIndex)) {
|
||||||
|
throw new TypeError("Dataframe colIndex is an unsupported type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* check for expected dimensionality / size */
|
||||||
|
if (
|
||||||
|
nCols !== columnarData.length ||
|
||||||
|
!columnarData.every(c => c.length === nRows)
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"Dataframe dimension does not match provided data shape"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nRows !== rowIndex.size()) {
|
||||||
|
throw new RangeError(
|
||||||
|
"Dataframe rowIndex must have same size as underlying data"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nCols !== colIndex.size()) {
|
||||||
|
throw new RangeError(
|
||||||
|
"Dataframe colIndex must have same size as underlying data"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static __compileColumn(column, getOffset, getLabel) {
|
||||||
|
/*
|
||||||
|
Each column accessor is a function which will lookup data by
|
||||||
|
index (ie, is equivalent to dataframe.get(row, col), where 'col'
|
||||||
|
is fixed.
|
||||||
|
|
||||||
|
In addition, each column accessor has several functions:
|
||||||
|
|
||||||
|
asArray() -- return the entire column as a native Array or TypedArray.
|
||||||
|
Crucially, this native array only supports label indexing.
|
||||||
|
Example:
|
||||||
|
const arr = df.col('a').asArray();
|
||||||
|
|
||||||
|
has(rlabel) -- return boolean indicating of the row label
|
||||||
|
is contained within the column. Example:
|
||||||
|
const isInColumn = df.col('a').includes(99)
|
||||||
|
For the default offset indexing, this is identical to:
|
||||||
|
const isInColumn = (99 > 0) && (99 < df.nRows);
|
||||||
|
|
||||||
|
ihas(roffset) -- same as has(), but accepts a row offset
|
||||||
|
instead of a row label.
|
||||||
|
|
||||||
|
indexOf(value) -- return the label (not offset) of the first instance of
|
||||||
|
'value' in the column. If you want the offset, just use the builtin JS
|
||||||
|
indexOf() function, available on both Array and TypedArray.
|
||||||
|
|
||||||
|
iget(offset) -- return the value at 'offset'
|
||||||
|
|
||||||
|
*/
|
||||||
|
const { length } = column;
|
||||||
|
|
||||||
|
/* get value by row label */
|
||||||
|
const get = function get(rlabel) {
|
||||||
|
return column[getOffset(rlabel)];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* get value by row offset */
|
||||||
|
const iget = function iget(roffset) {
|
||||||
|
return column[roffset];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* full column array access */
|
||||||
|
const asArray = function asArray() {
|
||||||
|
return column;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* test for row label inclusion in column */
|
||||||
|
const has = function has(rlabel) {
|
||||||
|
const offset = getOffset(rlabel);
|
||||||
|
return offset >= 0 && offset < length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ihas = function ihas(offset) {
|
||||||
|
return offset >= 0 && offset < length;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
return first label (index) at which the value is found in this column,
|
||||||
|
or undefined if not found.
|
||||||
|
|
||||||
|
NOTE: not found return is DIFFERENT than the default Array.indexOf as
|
||||||
|
-1 is a plausible Dataframe row/col label.
|
||||||
|
*/
|
||||||
|
const indexOf = function indexOf(value) {
|
||||||
|
const offset = column.indexOf(value);
|
||||||
|
if (offset === -1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return getLabel(offset);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Summarize the column data. Lazy eval;
|
||||||
|
*/
|
||||||
|
const summarize = callOnceLazy(() =>
|
||||||
|
isTypedArray(column)
|
||||||
|
? summarizeContinuous(column)
|
||||||
|
: summarizeCategorical(column)
|
||||||
|
);
|
||||||
|
|
||||||
|
get.summarize = summarize;
|
||||||
|
get.asArray = asArray;
|
||||||
|
get.has = has;
|
||||||
|
get.ihas = ihas;
|
||||||
|
get.indexOf = indexOf;
|
||||||
|
get.iget = iget;
|
||||||
|
return get;
|
||||||
|
}
|
||||||
|
|
||||||
|
__compile(accessors) {
|
||||||
|
/*
|
||||||
|
Compile data accessors for each column.
|
||||||
|
|
||||||
|
Use an existing accessor if provided, else compile a new one.
|
||||||
|
*/
|
||||||
|
const { getOffset, getLabel } = this.rowIndex;
|
||||||
|
this.__columnsAccessor = this.__columns.map((column, idx) => {
|
||||||
|
if (accessors[idx]) {
|
||||||
|
return accessors[idx];
|
||||||
|
}
|
||||||
|
return Dataframe.__compileColumn(column, getOffset, getLabel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clone() {
|
||||||
|
/*
|
||||||
|
Clone this dataframe
|
||||||
|
*/
|
||||||
|
return new this.constructor(
|
||||||
|
this.dims,
|
||||||
|
[...this.__columns],
|
||||||
|
this.rowIndex,
|
||||||
|
this.colIndex,
|
||||||
|
[...this.__columnsAccessor]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
withCol(label, colData, withRowIndex = null) {
|
||||||
|
/*
|
||||||
|
Create a new DF, which is `this` plus the new column. Example:
|
||||||
|
const newDf = df.withCol("foo", [1,2,3]);
|
||||||
|
|
||||||
|
Dimensionality of new column must match existing dataframe.
|
||||||
|
|
||||||
|
Special case: empty dataframe will accept any size column. Example:
|
||||||
|
const newDf = Dataframe.empty().withCol("foo", [1,2,3]);
|
||||||
|
|
||||||
|
If `withRowIndex` specified, the provided index will become the
|
||||||
|
rowIndex for the newly created dataframe. If not specified,
|
||||||
|
the rowIndex from `this` will be used (ie, the rowIndex is
|
||||||
|
unchanged).
|
||||||
|
*/
|
||||||
|
let dims;
|
||||||
|
let rowIndex;
|
||||||
|
if (this.isEmpty()) {
|
||||||
|
dims = [colData.length, 1];
|
||||||
|
rowIndex = null;
|
||||||
|
} else {
|
||||||
|
dims = [this.dims[0], this.dims[1] + 1];
|
||||||
|
({ rowIndex } = this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withRowIndex) {
|
||||||
|
rowIndex = withRowIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [...this.__columns];
|
||||||
|
columns.push(colData);
|
||||||
|
const colIndex = this.colIndex.withLabel(label);
|
||||||
|
const columnsAccessor = [...this.__columnsAccessor];
|
||||||
|
return new this.constructor(
|
||||||
|
dims,
|
||||||
|
columns,
|
||||||
|
rowIndex,
|
||||||
|
colIndex,
|
||||||
|
columnsAccessor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropCol(label) {
|
||||||
|
/*
|
||||||
|
Create a new dataframe, omitting one columns.
|
||||||
|
|
||||||
|
const newDf = df.dropCol("colors");
|
||||||
|
*/
|
||||||
|
const dims = [this.dims[0], this.dims[1] - 1];
|
||||||
|
const coffset = this.colIndex.getOffset(label);
|
||||||
|
const columns = [...this.__columns];
|
||||||
|
columns.splice(coffset, 1);
|
||||||
|
const colIndex = this.colIndex.dropLabel(label);
|
||||||
|
const columnsAccessor = [...this.__columnsAccessor];
|
||||||
|
columnsAccessor.splice(coffset, 1);
|
||||||
|
return new this.constructor(
|
||||||
|
dims,
|
||||||
|
columns,
|
||||||
|
this.rowIndex,
|
||||||
|
colIndex,
|
||||||
|
columnsAccessor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static empty(rowIndex = null, colIndex = null) {
|
||||||
|
return new Dataframe([0, 0], [], rowIndex, colIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(dims, columnarData) {
|
||||||
|
/*
|
||||||
|
Create a dataframe from raw columnar data. All column arrays
|
||||||
|
must have the same length. Identity indexing will be used.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
const df = Dataframe.create([2,2], [new Uint32Array(2), new Float32Array(2)]);
|
||||||
|
*/
|
||||||
|
return new Dataframe(dims, columnarData, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
__subset(rowOffsets, colOffsets, withRowIndex) {
|
||||||
|
const dims = [...this.dims];
|
||||||
|
|
||||||
|
const getSortedLabelAndOffsets = (offsets, index) => {
|
||||||
|
/*
|
||||||
|
Given offsets, return both offsets and associated lables,
|
||||||
|
sorted by offset.
|
||||||
|
*/
|
||||||
|
if (!offsets) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
const sortedOffsets = sortArray(offsets);
|
||||||
|
const sortedLabels = new Array(sortedOffsets.length);
|
||||||
|
for (let i = 0, l = sortedOffsets.length; i < l; i += 1) {
|
||||||
|
sortedLabels[i] = index.getLabel(sortedOffsets[i]);
|
||||||
|
}
|
||||||
|
return [sortedLabels, sortedOffsets];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { colIndex } = this;
|
||||||
|
if (colOffsets) {
|
||||||
|
let colLabels;
|
||||||
|
[colLabels, colOffsets] = getSortedLabelAndOffsets(
|
||||||
|
colOffsets,
|
||||||
|
this.colIndex
|
||||||
|
);
|
||||||
|
dims[1] = colOffsets.length;
|
||||||
|
colIndex = this.colIndex.subsetLabels(colLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
let { rowIndex } = this;
|
||||||
|
if (withRowIndex) rowIndex = withRowIndex;
|
||||||
|
if (rowOffsets) {
|
||||||
|
let rowLabels;
|
||||||
|
[rowLabels, rowOffsets] = getSortedLabelAndOffsets(
|
||||||
|
rowOffsets,
|
||||||
|
this.rowIndex
|
||||||
|
);
|
||||||
|
dims[0] = rowLabels.length;
|
||||||
|
if (!withRowIndex) rowIndex = this.rowIndex.subsetLabels(rowLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* subset columns */
|
||||||
|
let columns = this.__columns;
|
||||||
|
if (colOffsets) {
|
||||||
|
columns = new Array(colOffsets.length);
|
||||||
|
for (let i = 0, l = colOffsets.length; i < l; i += 1) {
|
||||||
|
columns[i] = this.__columns[colOffsets[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* subset rows */
|
||||||
|
if (rowOffsets) {
|
||||||
|
columns = columns.map(col => {
|
||||||
|
const newCol = new col.constructor(rowOffsets.length);
|
||||||
|
for (let i = 0, l = rowOffsets.length; i < l; i += 1) {
|
||||||
|
newCol[i] = col[rowOffsets[i]];
|
||||||
|
}
|
||||||
|
return newCol;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Dataframe(dims, columns, rowIndex, colIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
subset(rowLabels, colLabels = null, withRowIndex = null) {
|
||||||
|
/*
|
||||||
|
Subset by row/col labels.
|
||||||
|
|
||||||
|
withRowIndex allows assignment of new row index during subset operation.
|
||||||
|
If withRowIndex === null, it will reset the index to identity (offset)
|
||||||
|
indexing. if withRowIndex is a label index object, it will be used
|
||||||
|
for the new dataframe.
|
||||||
|
*/
|
||||||
|
const toOffsets = (labels, index) => {
|
||||||
|
if (!labels) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return labels.map(label => {
|
||||||
|
const off = index.getOffset(label);
|
||||||
|
if (off === undefined) {
|
||||||
|
throw new RangeError(`unknown label: ${label}`);
|
||||||
|
}
|
||||||
|
return off;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowOffsets = toOffsets(rowLabels, this.rowIndex);
|
||||||
|
const colOffsets = toOffsets(colLabels, this.colIndex);
|
||||||
|
return this.__subset(rowOffsets, colOffsets, withRowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
isubset(rowOffsets, colOffsets = null, withRowIndex = null) {
|
||||||
|
/*
|
||||||
|
Subset by row/col offset.
|
||||||
|
|
||||||
|
withRowIndex allows assignment of new row index during subset operation.
|
||||||
|
If withRowIndex === null, it will reset the index to identity (offset)
|
||||||
|
indexing. if withRowIndex is a label index object, it will be used
|
||||||
|
for the new dataframe.
|
||||||
|
*/
|
||||||
|
return this.__subset(rowOffsets, colOffsets, withRowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
isubsetMask(rowMask, colMask = null, withRowIndex = null) {
|
||||||
|
/*
|
||||||
|
Subset on row/column based upon a truthy/falsey array (a mask).
|
||||||
|
|
||||||
|
withRowIndex allows assignment of new row index during subset operation.
|
||||||
|
If withRowIndex === null, it will reset the index to identity (offset)
|
||||||
|
indexing. if withRowIndex is a label index object, it will be used
|
||||||
|
for the new dataframe.
|
||||||
|
*/
|
||||||
|
const [nRows, nCols] = this.dims;
|
||||||
|
if (
|
||||||
|
(rowMask && rowMask.length !== nRows) ||
|
||||||
|
(colMask && colMask.length !== nCols)
|
||||||
|
) {
|
||||||
|
throw new RangeError("boolean arrays must match row/col dimensions");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* convert masks to lists - method wastes space, but is fast */
|
||||||
|
const toList = (mask, maxSize) => {
|
||||||
|
if (!mask) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const list = new Int32Array(maxSize);
|
||||||
|
let elems = 0;
|
||||||
|
for (let i = 0, l = mask.length; i < l; i += 1) {
|
||||||
|
if (mask[i]) {
|
||||||
|
list[elems] = i;
|
||||||
|
elems += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Int32Array(list.buffer, 0, elems);
|
||||||
|
};
|
||||||
|
const rowOffsets = toList(rowMask, nRows);
|
||||||
|
const colOffsets = toList(colMask, nCols);
|
||||||
|
return this.__subset(rowOffsets, colOffsets, withRowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Data access with row/col.
|
||||||
|
**/
|
||||||
|
|
||||||
|
col(columnLabel) {
|
||||||
|
/*
|
||||||
|
Return accessor bound to a column. Allows random row access
|
||||||
|
based upon the row indexing. Returns undefined if the
|
||||||
|
columnLabel is not present in the dataframe.
|
||||||
|
|
||||||
|
Example for a dataframe with string labeled columns, and
|
||||||
|
default (offset) indices for rows (eg, [0, 'foo'])
|
||||||
|
|
||||||
|
const getValue = df.col('foo');
|
||||||
|
for (let r = 0; r < df.nRows; r += 1) {
|
||||||
|
console.log(r, getValue(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
See __compile() for the functions available in a column accessor.
|
||||||
|
*/
|
||||||
|
const coff = this.colIndex.getOffset(columnLabel);
|
||||||
|
return this.__columnsAccessor[coff];
|
||||||
|
}
|
||||||
|
|
||||||
|
icol(columnOffset) {
|
||||||
|
/*
|
||||||
|
Return column accessor by offset.
|
||||||
|
*/
|
||||||
|
return this.__columnsAccessor[columnOffset];
|
||||||
|
}
|
||||||
|
|
||||||
|
at(r, c) {
|
||||||
|
/*
|
||||||
|
Access a single value, for a row/col label pair.
|
||||||
|
|
||||||
|
For performance reasons, there are no bounds or existance
|
||||||
|
checks on labels, and no defined behavior when these are supplied.
|
||||||
|
May return undefined, throw an Error, or do something else for
|
||||||
|
non-existant labels. If you want predictable out-of-bounds
|
||||||
|
behavior, use has(), eg,
|
||||||
|
|
||||||
|
const myVal = df.has(r,l) ? df.at(r,l) : undefined;
|
||||||
|
*/
|
||||||
|
const coff = this.colIndex.getOffset(c);
|
||||||
|
const roff = this.rowIndex.getOffset(r);
|
||||||
|
return this.__columns[coff][roff];
|
||||||
|
}
|
||||||
|
|
||||||
|
iat(r, c) {
|
||||||
|
/*
|
||||||
|
Access a single value, for a row/col offset (integer) position.
|
||||||
|
|
||||||
|
For performance reasons, there are no bounds checks on row/col offsets
|
||||||
|
or other well-defined behavior for out-of-bounds values. If you want
|
||||||
|
well-defined bounds checking, use ihas(), eg,
|
||||||
|
|
||||||
|
const myVal = df.ihas(r, c) ? df.iat(r, c) : undefined;
|
||||||
|
*/
|
||||||
|
return this.__columns[c][r];
|
||||||
|
}
|
||||||
|
|
||||||
|
has(r, c) {
|
||||||
|
/*
|
||||||
|
Test if row/col labels exist in the dataframe - returns true/false
|
||||||
|
*/
|
||||||
|
const [nRows, nCols] = this.dims;
|
||||||
|
const coff = this.colIndex.getOffset(c);
|
||||||
|
const roff = this.rowIndex.getOffset(r);
|
||||||
|
return coff >= 0 && coff < nCols && roff >= 0 && roff < nRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
ihas(r, c) {
|
||||||
|
/*
|
||||||
|
Test if row/col offset (integer) position exists in the
|
||||||
|
dataframe - returns true/false
|
||||||
|
*/
|
||||||
|
const [nRows, nCols] = this.dims;
|
||||||
|
return c >= 0 && c < nCols && r >= 0 && r < nRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCol(c) {
|
||||||
|
/*
|
||||||
|
Test if col label exists - return true/false
|
||||||
|
*/
|
||||||
|
return !!this.col(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty() {
|
||||||
|
/*
|
||||||
|
Return true if this is an empty dataframe, ie, has dimensions [0,0]
|
||||||
|
*/
|
||||||
|
const [rows, cols] = this.dims;
|
||||||
|
return rows === 0 && cols === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/****
|
||||||
|
Functional (map/reduce/etc) data access
|
||||||
|
|
||||||
|
TODO: most are not yet implemented, as there is no clear use case. Can easily
|
||||||
|
add these as useful.
|
||||||
|
****/
|
||||||
|
|
||||||
|
mapColumns(callback) {
|
||||||
|
/*
|
||||||
|
map all columns in the dataframe, returning a new dataframe comprised of the
|
||||||
|
return values, with the same index as the original dataframe.
|
||||||
|
|
||||||
|
callback MUST not modify the column, but instead return a mutated copy.
|
||||||
|
*/
|
||||||
|
const columns = this.__columns.map(callback);
|
||||||
|
const columnsAccessor = columns.map((c, idx) =>
|
||||||
|
this.__columns[idx] === c ? this.__columnsAccessor[idx] : undefined
|
||||||
|
);
|
||||||
|
return new this.constructor(
|
||||||
|
this.dims,
|
||||||
|
columns,
|
||||||
|
this.rowIndex,
|
||||||
|
this.colIndex,
|
||||||
|
columnsAccessor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Map & reduce of column or row
|
||||||
|
|
||||||
|
TODO remainder of map/reduce functions: mapCol, mapRow, reduceRow, ...
|
||||||
|
*/
|
||||||
|
/* comment out until we have a use for this
|
||||||
|
|
||||||
|
reduceCol(clabel, callback, initialValue) {
|
||||||
|
const coff = this.colIndex.getOffset(clabel);
|
||||||
|
const column = this.__columns[coff];
|
||||||
|
let start = 0;
|
||||||
|
let acc = initialValue;
|
||||||
|
if (initialValue === undefined) {
|
||||||
|
acc = column[0];
|
||||||
|
start = 1;
|
||||||
|
}
|
||||||
|
for (let i = start, l = column.length; i < l; i += 1) {
|
||||||
|
acc = callback(acc, column[i]);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dataframe;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user