mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 15:08:12 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c2f57df39 | ||
|
|
9bc83fdd02 | ||
|
|
bbfd2e5ac5 | ||
|
|
f28bd52d79 | ||
|
|
63f1f38f7c | ||
|
|
3ba4cf4483 | ||
|
|
a862f2b2a4 | ||
|
|
3bf72829c2 | ||
|
|
7cd9a0032a | ||
|
|
9089fc98f2 | ||
|
|
8beeb57c96 | ||
|
|
234f25b782 | ||
|
|
28ddb60b2f | ||
|
|
1a93068810 | ||
|
|
d8231a9a3b | ||
|
|
ffdcc24a40 | ||
|
|
023a73cab8 | ||
|
|
144b19c449 | ||
|
|
b3e9719602 | ||
|
|
bdd69160e2 | ||
|
|
bddf66d62a | ||
|
|
7b77bf4bdd | ||
|
|
cc890fe391 | ||
|
|
62e944796b | ||
|
|
1547a864c2 | ||
|
|
05323ae643 | ||
|
|
87efcc0e99 | ||
|
|
bf7d7342d5 | ||
|
|
1e4381ab7f | ||
|
|
8b0adcdfcc | ||
|
|
0043308b52 | ||
|
|
8a62c9f027 | ||
|
|
ded35c7252 | ||
|
|
46876e2fb1 | ||
|
|
7bc58bba2b | ||
|
|
5c70cc5bcd | ||
|
|
281154f591 | ||
|
|
c7f2032dd7 | ||
|
|
fb1f0c6469 | ||
|
|
d2bae0c4f6 | ||
|
|
907cc634f5 | ||
|
|
349c413d8b | ||
|
|
c630be33df | ||
|
|
d30087ca63 | ||
|
|
ac13b31e13 | ||
|
|
5340a5f951 | ||
|
|
e770db1e2c | ||
|
|
1c9b9f6a08 | ||
|
|
3afc03b95b | ||
|
|
a927d733c2 | ||
|
|
3dd50e5676 | ||
|
|
588ddc103a | ||
|
|
9bdcf44564 | ||
|
|
8b8a800453 | ||
|
|
d48647a655 | ||
|
|
8d725b1ad9 | ||
|
|
69c639b14d | ||
|
|
431c8bdabc | ||
|
|
2832393f40 | ||
|
|
8b329630f8 | ||
|
|
30f3ce49e4 | ||
|
|
ddbfc458d7 | ||
|
|
124254d5ed | ||
|
|
677433bbf5 | ||
|
|
e1ff800980 | ||
|
|
2d39cb3b30 | ||
|
|
18177b108f | ||
|
|
be54d583f1 | ||
|
|
4c1ecf9e52 | ||
|
|
b172ff7f15 | ||
|
|
327d005a1f | ||
|
|
0d2cb14796 | ||
|
|
97738e5c2d | ||
|
|
dfaf0d0603 | ||
|
|
0b1bdca022 | ||
|
|
6370025caf | ||
|
|
53a6d01fa3 | ||
|
|
29947e25c3 | ||
|
|
f3015cb9df | ||
|
|
ec79995be8 | ||
|
|
517e0f3826 | ||
|
|
5812e00e01 | ||
|
|
1ed68261d4 | ||
|
|
9c09ee6329 | ||
|
|
c9b6044c4f | ||
|
|
fa89a5a375 | ||
|
|
f8cef71eed | ||
|
|
bb3032ea2f | ||
|
|
5e471349e7 | ||
|
|
0aee4b89e0 | ||
|
|
d2171b9a2f | ||
|
|
a593e95ab3 | ||
|
|
575f71aaee | ||
|
|
04aa6f8fe9 | ||
|
|
298ded6e2d | ||
|
|
9bcafeb7c1 | ||
|
|
85c1b6957e | ||
|
|
2f99b317a2 | ||
|
|
8535664e9c | ||
|
|
5ed9f5126b | ||
|
|
5713aa8792 | ||
|
|
9aaaad6709 | ||
|
|
621b88bff4 | ||
|
|
ecedf666c7 | ||
|
|
afff2ceec7 | ||
|
|
be9a415a4a | ||
|
|
0295041972 | ||
|
|
8fe7ee75e8 | ||
|
|
a3d96aae0a | ||
|
|
d5d56d89d1 | ||
|
|
a6a2841961 | ||
|
|
7bf76fa557 | ||
|
|
c2ededfc62 | ||
|
|
ee62dd355f | ||
|
|
0415c30f0b | ||
|
|
79e534e397 | ||
|
|
f4919340e9 | ||
|
|
976f9d9ff8 | ||
|
|
9a540a4f0a | ||
|
|
5968da18b4 | ||
|
|
bd5f816d58 | ||
|
|
cf8f1a710d | ||
|
|
2d0b646956 | ||
|
|
b284e6f820 | ||
|
|
5ea9ac8a9e | ||
|
|
89d748b007 | ||
|
|
68ad4b1228 | ||
|
|
a52e86a69b | ||
|
|
a5d343aaa4 | ||
|
|
04c83993d2 | ||
|
|
54d144bb75 | ||
|
|
bf3bf58d6e | ||
|
|
611c8b98b1 | ||
|
|
d08506ae12 | ||
|
|
238b7420f7 | ||
|
|
cadf710bf8 | ||
|
|
9c8c28e1cb | ||
|
|
84900bad0e | ||
|
|
4c3d783fc8 | ||
|
|
461084eca1 | ||
|
|
341d3015f0 | ||
|
|
6a95d956a9 | ||
|
|
7599af451f | ||
|
|
823d819eee | ||
|
|
a16c2e2747 | ||
|
|
e50fd8b881 | ||
|
|
3e61bb807d | ||
|
|
345e2c344b | ||
|
|
3f0567c59f | ||
|
|
934f61f2a1 | ||
|
|
60a23b681c | ||
|
|
e581dec8f5 | ||
|
|
711f3b7048 | ||
|
|
1467357db5 | ||
|
|
de6721b682 | ||
|
|
296352b620 | ||
|
|
3f2811d9da | ||
|
|
700c871e6d | ||
|
|
f865187a5d | ||
|
|
ad290b3630 | ||
|
|
21218f1c59 | ||
|
|
f41f47cf1b | ||
|
|
d94508d534 | ||
|
|
44cb276cdf | ||
|
|
e64f4f06fb | ||
|
|
32feb36172 | ||
|
|
c983e878e9 | ||
|
|
077541c762 | ||
|
|
3d15db7c5e | ||
|
|
109c9e70ec | ||
|
|
197ea9214a | ||
|
|
81e57afe29 | ||
|
|
3660a6cc27 | ||
|
|
ab2c423006 | ||
|
|
922c375333 | ||
|
|
ec4da63408 | ||
|
|
20317fd08f | ||
|
|
0f520f2fd4 | ||
|
|
84c815cc04 | ||
|
|
77de22d1ed | ||
|
|
c35f94fe6d | ||
|
|
78a43402cb | ||
|
|
b0562a11fd | ||
|
|
b309367bb0 | ||
|
|
b359a610da | ||
|
|
0f36b49840 | ||
|
|
28c4d28308 | ||
|
|
b8c05763fe | ||
|
|
613f1a911f | ||
|
|
bbb521537b | ||
|
|
79a503e436 | ||
|
|
69e69f4dae | ||
|
|
fcaa30d7cc | ||
|
|
03baa1ac0b | ||
|
|
906d65c06f | ||
|
|
57b1c3cbb4 | ||
|
|
40c533a45b | ||
|
|
9eb8185e82 | ||
|
|
6a86883c2d | ||
|
|
d16a69e158 | ||
|
|
3d6bb88556 | ||
|
|
e199b14259 | ||
|
|
9c05aa6766 | ||
|
|
3833bada54 | ||
|
|
3d98797d8c | ||
|
|
777214cc14 | ||
|
|
9f0f60b5eb | ||
|
|
ca20add577 | ||
|
|
98b07b1284 | ||
|
|
941c297363 | ||
|
|
722a91f1d2 | ||
|
|
acdc810f82 | ||
|
|
8afb22a017 | ||
|
|
5effe4bbbb | ||
|
|
df1109e920 | ||
|
|
2df93d6d94 | ||
|
|
d5deb1579f | ||
|
|
334b8bb8da | ||
|
|
6aeefb0fe6 | ||
|
|
eac514e04d | ||
|
|
afeddad343 | ||
|
|
0f17b84dc1 | ||
|
|
3edb87d125 | ||
|
|
9f9393a486 | ||
|
|
1d6bb032a3 | ||
|
|
3152de4b7f | ||
|
|
ca9a6796d8 | ||
|
|
a6142bdf93 | ||
|
|
ffd7f0db49 | ||
|
|
c6252825f3 | ||
|
|
4b96b3a635 | ||
|
|
862d8feb5e | ||
|
|
1ef77d1596 | ||
|
|
3dc45d6330 | ||
|
|
a8c2e408d1 | ||
|
|
e941c1a496 | ||
|
|
a657eb3152 | ||
|
|
ef7c26e799 | ||
|
|
49af278de7 | ||
|
|
2357d0c1b8 | ||
|
|
63af79d3f8 | ||
|
|
fcc05f6a00 | ||
|
|
de3407d875 | ||
|
|
2d4e827bea | ||
|
|
1fa4838863 | ||
|
|
ab4c74a321 | ||
|
|
82d65addec | ||
|
|
e2ad28a510 | ||
|
|
efa1709158 | ||
|
|
d6040f687a | ||
|
|
b9a1e30652 | ||
|
|
7adac5d004 | ||
|
|
2354731083 | ||
|
|
d522cc8f91 | ||
|
|
86eb01eb2c | ||
|
|
8a94b1e086 | ||
|
|
846b8d15bd | ||
|
|
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 |
+5
-5
@@ -1,15 +1,15 @@
|
||||
[bumpversion]
|
||||
current_version = 0.0.3
|
||||
current_version = 0.15.0
|
||||
|
||||
[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}"
|
||||
|
||||
[bumpversion:file:server/__init__.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
@@ -0,0 +1,5 @@
|
||||
bin
|
||||
client
|
||||
dist
|
||||
docs
|
||||
server
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Compatibility Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 7 * 2'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
JEST_ENV: prod
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Build docker image
|
||||
run: docker build .
|
||||
|
||||
cellxgene-master-with-python-and-anndata-versions:
|
||||
name: python versions x anndata versions
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
anndata-version: [0.6.22.post1, 0.7.1]
|
||||
test-suite: [smoke-test, smoke-test-annotations]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# 1. only install the dev requirements on top of what is in the cellxgene pip package
|
||||
sudo apt-get update && sudo apt-get install -y libhdf5-serial-dev
|
||||
sed -i 's/-r requirements.txt//' server/requirements-dev.txt
|
||||
pip install -r server/requirements-dev.txt
|
||||
# 2. install cellxgene
|
||||
make pydist install-dist
|
||||
# 3. install anndata
|
||||
pip install anndata==${{ matrix.anndata-version }}
|
||||
# workaround for anndata 0.6.22.post1 bug
|
||||
[[ "0.6.22.post1" = "${{ matrix.anndata-version }}" ]] && pip install h5py==2.9.0 || true
|
||||
- name: Tests
|
||||
run: make unit-test ${{ matrix.test-suite }}
|
||||
|
||||
cellxgene-release-with-anndata-master:
|
||||
name: cellxgene release with anndata master
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
test-suite: [smoke-test, smoke-test-annotations]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
path: cellxgene
|
||||
- name: Checkout tools repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: theislab/anndata
|
||||
path: anndata
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd cellxgene
|
||||
# 1. only install the dev requirements on top of what is in the cellxgene pip package
|
||||
make dev-env-client
|
||||
sed -i 's/-r requirements.txt//' server/requirements-dev.txt
|
||||
pip install -r server/requirements-dev.txt
|
||||
# 2. install cellxgene
|
||||
pip install --upgrade cellxgene
|
||||
# 3. install anndata
|
||||
cd ../anndata && pip install -e .
|
||||
- name: Tests
|
||||
run: cd cellxgene && make unit-test ${{ matrix.test-suite }}
|
||||
|
||||
cellxgene-master-with-anndata-master:
|
||||
name: cellxgene master with anndata master
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
test-suite: [smoke-test, smoke-test-annotations]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
path: cellxgene
|
||||
- name: Checkout tools repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: theislab/anndata
|
||||
path: anndata
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd cellxgene
|
||||
sed -i -E 's/^anndata[>=]=[0-9]+.[0-9]+.[0-9]+$/anndata/g' server/requirements.txt
|
||||
make pydist install-dist dev-env
|
||||
cd ../anndata
|
||||
pip install -e .
|
||||
- name: Tests
|
||||
run: cd cellxgene && make unit-test ${{ matrix.test-suite }}
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Push Tests
|
||||
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
JEST_ENV: prod
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
pip install flake8
|
||||
make lint
|
||||
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Python cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Node cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Install dependencies
|
||||
run: make pydist install-dist dev-env-server
|
||||
- name: Unit tests
|
||||
run: |
|
||||
make unit-test
|
||||
bash <(curl -s https://codecov.io/bash) -y .codecov.yml -k server -cF backend,python,unitTest
|
||||
cd client && ./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,unitTest
|
||||
|
||||
smoke-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Python cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Node cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Install dependencies
|
||||
run: make pydist install-dist
|
||||
- name: Smoke tests (without annotations feature)
|
||||
run: |
|
||||
cd client && make smoke-test
|
||||
./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,smokeTest
|
||||
|
||||
smoke-tests-annotations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Python cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Node cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Install dependencies
|
||||
run: make pydist install-dist
|
||||
- name: Smoke tests (with annotations feature)
|
||||
run: |
|
||||
cd client && make smoke-test-annotations
|
||||
./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,smokeTestAnnotations
|
||||
+15
-5
@@ -3,6 +3,8 @@ node_modules/
|
||||
|
||||
# coverage
|
||||
coverage
|
||||
coverage.xml
|
||||
.coverage
|
||||
.nyc_output
|
||||
|
||||
.pytest_cache/
|
||||
@@ -17,16 +19,20 @@ 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
|
||||
server/common/web/static/css/
|
||||
server/common/web/static/img/
|
||||
server/common/web/static/media/
|
||||
server/common/web/static/fonts/
|
||||
server/common/web/static/js/
|
||||
server/common/web/templates/index\.html
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
*.ipynb
|
||||
|
||||
# cefpython
|
||||
error.log
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
npm-debug.log
|
||||
@@ -35,3 +41,7 @@ npm-debug.log
|
||||
__pycache__
|
||||
*.DS_Store*
|
||||
data
|
||||
|
||||
# Jekyll
|
||||
docs/_site/
|
||||
docs/Gemfile.lock
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
node_js:
|
||||
- "8"
|
||||
cache:
|
||||
pip: true
|
||||
install:
|
||||
- set -eo pipefail
|
||||
- pip install flake8
|
||||
- ./bin/build-client
|
||||
- pip install -e .
|
||||
- pip install -r server/requirements-dev.txt
|
||||
script:
|
||||
- set -eo pipefail
|
||||
- flake8 server/app/
|
||||
- flake8 server/cli/
|
||||
- npm run --prefix client/ build
|
||||
- npm run --prefix client/ test
|
||||
- pytest -s server/test
|
||||
@@ -0,0 +1,3 @@
|
||||
We warmly welcome contributions from the community!
|
||||
|
||||
Whether you want to contribute ideas, requests, documentation, or code, you can get started by visiting our [contribution guide](https://chanzuckerberg.github.io/cellxgene/posts/contribute).
|
||||
+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 python3-requests && \
|
||||
pip3 install cellxgene
|
||||
|
||||
ENTRYPOINT ["cellxgene"]
|
||||
+4
-2
@@ -1,3 +1,5 @@
|
||||
recursive-include server/app/web/templates *
|
||||
recursive-include server/app/web/static *
|
||||
recursive-include server/common/web/templates *
|
||||
recursive-include server/common/web/static *
|
||||
|
||||
include server/requirements.txt
|
||||
include server/requirements-prepare.txt
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
include common.mk
|
||||
|
||||
BUILDDIR := build
|
||||
CLIENTBUILD := $(BUILDDIR)/client
|
||||
SERVERBUILD := $(BUILDDIR)/server
|
||||
CLEANFILES := $(BUILDDIR)/ client/build build dist cellxgene.egg-info
|
||||
|
||||
PART ?= patch
|
||||
|
||||
# CLEANING
|
||||
.PHONY: clean
|
||||
clean: clean-lite clean-server clean-client
|
||||
|
||||
# cleaning the client's node_modules is the longest one, so we avoid that if possible
|
||||
.PHONY: clean-lite
|
||||
clean-lite:
|
||||
rm -rf $(CLEANFILES)
|
||||
|
||||
clean-%:
|
||||
cd $(*) && $(MAKE) clean
|
||||
|
||||
|
||||
# BUILDING PACKAGE
|
||||
|
||||
.PHONY: build
|
||||
build: clean build-cli
|
||||
@echo "done"
|
||||
|
||||
.PHONY: build-client
|
||||
build-client:
|
||||
cd client && $(MAKE) ci build
|
||||
|
||||
.PHONY: build-cli
|
||||
build-cli: build-client
|
||||
git ls-files server/ | cpio -pdm $(BUILDDIR)
|
||||
cp -r client/build/ $(CLIENTBUILD)
|
||||
$(call copy_client_assets,$(CLIENTBUILD),$(SERVERBUILD))
|
||||
cp MANIFEST.in README.md setup.cfg setup.py $(BUILDDIR)
|
||||
|
||||
# If you are actively developing in the server folder use this, dirties the source tree
|
||||
.PHONY: build-for-server-dev
|
||||
build-for-server-dev: clean-server build-client
|
||||
$(call copy_client_assets,client/build,server)
|
||||
|
||||
# TESTING
|
||||
.PHONY: test
|
||||
test: unit-test smoke-test
|
||||
|
||||
.PHONY: unit-test
|
||||
unit-test: unit-test-server unit-test-client
|
||||
|
||||
unit-test-%:
|
||||
cd $(*) && $(MAKE) unit-test
|
||||
|
||||
.PHONY: smoke-test
|
||||
smoke-test:
|
||||
cd client && $(MAKE) smoke-test
|
||||
|
||||
.PHONY: smoke-test-annotations
|
||||
smoke-test-annotations:
|
||||
cd client && $(MAKE) smoke-test-annotations
|
||||
|
||||
# FORMATTING CODE
|
||||
|
||||
.PHOHY: fmt
|
||||
fmt: fmt-client fmt-py
|
||||
|
||||
.PHONY: fmt-client
|
||||
fmt-client:
|
||||
cd client && $(MAKE) fmt
|
||||
|
||||
.PHONY: fmt
|
||||
fmt-py:
|
||||
black .
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
flake8 server
|
||||
|
||||
|
||||
# CREATING DISTRIBUTION RELEASE
|
||||
|
||||
.PHONY: pydist
|
||||
pydist: build
|
||||
cd $(BUILDDIR); python setup.py sdist -d ../dist
|
||||
@echo "done"
|
||||
|
||||
|
||||
# RELEASE HELPERS
|
||||
|
||||
# create new version to commit to master
|
||||
.PHONY: release-stage-1
|
||||
release-stage-1: dev-env bump clean-lite gen-package-lock
|
||||
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
|
||||
|
||||
# build dist and release to dev pypi
|
||||
.PHONY: release-stage-2
|
||||
release-stage-2: dev-env pydist twine
|
||||
@echo "Dist built and uploaded to test.pypi.org"
|
||||
@echo "Test the install:"
|
||||
@echo " make install-release-test"
|
||||
@echo "Then upload to Pypi prod:"
|
||||
@echo " make twine-prod"
|
||||
|
||||
.PHONY: release-stage-final
|
||||
release-stage-final: twine-prod
|
||||
@echo "Release uploaded to pypi.org"
|
||||
|
||||
# DANGER: releases directly to prod
|
||||
# use this if you accidently burned a test release version number,
|
||||
.PHONY: release-directly-to-prod
|
||||
release-directly-to-prod: dev-env pydist twine-prod
|
||||
@echo "Dist built and uploaded to pypi.org"
|
||||
@echo "Test the install:"
|
||||
@echo " make install-release"
|
||||
|
||||
.PHONY: dev-env
|
||||
dev-env: dev-env-client dev-env-server
|
||||
|
||||
.PHONY: dev-env-client
|
||||
dev-env-client:
|
||||
cd client && $(MAKE) ci
|
||||
|
||||
.PHONY: dev-env-server
|
||||
dev-env-server:
|
||||
pip install -r server/requirements-dev.txt
|
||||
|
||||
# give PART=[major, minor, part] as param to make bump
|
||||
.PHONY: bump
|
||||
bump:
|
||||
bumpversion --config-file .bumpversion.cfg $(PART)
|
||||
|
||||
.PHONY: twine
|
||||
twine:
|
||||
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
|
||||
|
||||
.PHONY: twine-prod
|
||||
twine-prod:
|
||||
twine upload dist/*
|
||||
|
||||
# quicker than re-building client
|
||||
.PHONY: gen-package-lock
|
||||
gen-package-lock:
|
||||
cd client && $(MAKE) install
|
||||
|
||||
|
||||
# INSTALL
|
||||
|
||||
# setup.py sucks when you have your library in a separate folder, adding these in to help setup envs
|
||||
|
||||
# install from build directory
|
||||
.PHONY: install
|
||||
install: uninstall
|
||||
cd $(BUILDDIR); pip install -e .
|
||||
|
||||
# install from source tree for development
|
||||
.PHONY: install-dev
|
||||
install-dev: uninstall
|
||||
pip install -e .
|
||||
|
||||
# install from test.pypi to test your release
|
||||
.PHONY: install-release-test
|
||||
install-release-test: uninstall
|
||||
pip install --no-cache-dir --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene
|
||||
@echo "Installed cellxgene from test.pypi.org, now run and smoke test"
|
||||
|
||||
# install from pypi to test your release
|
||||
.PHONY: install-release
|
||||
install-release: uninstall
|
||||
pip install --no-cache-dir cellxgene
|
||||
@echo "Installed cellxgene from pypi.org"
|
||||
|
||||
# install from dist
|
||||
.PHONY: install-dist
|
||||
install-dist: uninstall
|
||||
pip install dist/cellxgene*.tar.gz
|
||||
|
||||
.PHONY: uninstall
|
||||
uninstall:
|
||||
pip uninstall -y cellxgene || :
|
||||
|
||||
@@ -1,111 +1,89 @@
|
||||
# cellxgene
|
||||
<img src="./docs/cellxgene-logo.png" width="300">
|
||||
|
||||
### An interactive, performant explorer for single cell transcriptomics data.
|
||||
_an interactive explorer for single-cell transcriptomics data_
|
||||
|
||||
<img align="right" width="350" height="218" src="./example-dataset/cellxgene-demo.gif" pad="50px">
|
||||
cellxgene is an open-source experiment in how to bring powerful tools from modern web development to visualize and explore large single-cell transcriptomics datasets.
|
||||
Started in the context of the Human Cell Atlas Consortium, cellxgene hopes to both enable scientists to explore their data and to equip developers with scalable, reusable patterns and frameworks for visualizing large scientific datasets.
|
||||
[](https://zenodo.org/badge/latestdoi/105615409) [](https://pypi.org/project/cellxgene/) [](https://pypistats.org/packages/cellxgene) [](https://github.com/chanzuckerberg/cellxgene/pulse)
|
||||
[](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Push+Tests%22)
|
||||
[](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Compatibility+Tests%22)
|
||||

|
||||
|
||||
## Features
|
||||
cellxgene (pronounced "cell-by-gene") is an interactive data explorer for single-cell transcriptomics datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data.
|
||||
|
||||
- **Visualization at scale:** built with [WebGL](https://www.khronos.org/webgl/), [React](https://reactjs.org/) & [Redux](https://redux.js.org/) to handle visualization of at least 1 million cells.
|
||||
Whether you need to visualize one thousand cells or one million, cellxgene helps you gain insight into your single-cell data.
|
||||
|
||||
- **Interactive exploration:** select, cross-filter, and compare subsets of your data with performant indexing and data handling.
|
||||
<img src="https://github.com/chanzuckerberg/cellxgene/raw/master/docs/images/crossfilter.gif" width="350" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/raw/master/docs/images/category-breakdown.gif" width="350" height="200" hspace="30">
|
||||
|
||||
- **Flexible API:** the cellxgene client-server model is designed to support a range of existing analysis packages for backend computational tasks (eg scanpy), integrated with client-side visualization via a [REST API](https://restfulapi.net/).
|
||||
# Getting started
|
||||
### The comprehensive guide to cellxgene
|
||||
[The cellxgene documentation is your one-stop-shop for information about cellxgene](https://chanzuckerberg.github.io/cellxgene/)! You may be particularly interested in:
|
||||
* Seeing [what cellxgene can do](https://chanzuckerberg.github.io/cellxgene/posts/gallery)
|
||||
* Learning more about cellxgene [installation](https://chanzuckerberg.github.io/cellxgene/posts/install) and [usage](https://chanzuckerberg.github.io/cellxgene/posts/launch)
|
||||
* [Preparing your own data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) for use in cellxgene
|
||||
* Checking out [our roadmap](https://chanzuckerberg.github.io/cellxgene/posts/roadmap) for future development
|
||||
* [Contributing](https://chanzuckerberg.github.io/cellxgene/posts/contribute) to cellxgene
|
||||
|
||||
## Getting Started
|
||||
### Quick start
|
||||
|
||||
**Requirements**
|
||||
To install cellxgene you need Python 3.6+. We recommend [installing cellxgene into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/posts/install)
|
||||
|
||||
- OS: OSX, Windows, Linux -- the developers are currently testing on OSX and Windows (via WSL using Ubuntu). It should work on other platforms but if you are using something different and need help, please let us know.
|
||||
- python 3.6
|
||||
- python3 tkinter
|
||||
- npm
|
||||
- Google Chrome
|
||||
Install the package.
|
||||
``` bash
|
||||
pip install cellxgene
|
||||
```
|
||||
|
||||
**Clone project**
|
||||
Launch cellxgene with an example [anndata](https://anndata.readthedocs.io/en/latest/) file
|
||||
|
||||
git clone https://github.com/chanzuckerberg/cellxgene.git
|
||||
``` bash
|
||||
cellxgene launch https://cellxgene-example-data.czi.technology/pbmc3k.h5ad
|
||||
```
|
||||
|
||||
**Install client**
|
||||
To explore more datasets already formatted for cellxgene, check out the [Demo data](https://chanzuckerberg.github.io/cellxgene/posts/demo-data) or
|
||||
see [Preparing your data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) to learn more about formatting your own
|
||||
data for cellxgene.
|
||||
|
||||
cd cellxgene
|
||||
./bin/build-client
|
||||
### Finding help
|
||||
|
||||
**To use with virtual env for python**
|
||||
(optional, but recommended)
|
||||
We'd love to hear from you!
|
||||
For questions, suggestions, or accolades, [join the `#cellxgene-users` channel on the CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and say "hi!".
|
||||
|
||||
ENV_NAME=cellxgene
|
||||
python3 -m venv ${ENV_NAME}
|
||||
source ${ENV_NAME}/bin/activate
|
||||
For any errors, [report bugs on Github](https://github.com/chanzuckerberg/cellxgene/issues).
|
||||
|
||||
**Install server**
|
||||
# Developing with cellxgene
|
||||
|
||||
pip install -e .
|
||||
### Contributing
|
||||
We warmly welcome contributions from the community! Please see our [contributing guide](https://chanzuckerberg.github.io/cellxgene/posts/contribute) and don't hesitate to open an issue or send a pull request to improve cellxgene.
|
||||
|
||||
**Run (with demo data)**
|
||||
This project adheres to the Contributor Covenant [code of conduct](https://github.com/chanzuckerberg/.github/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to opensource@chanzuckerberg.com.
|
||||
|
||||
cellxgene launch --title PBMC3K example-dataset/pbmc3k.h5ad
|
||||
### Reuse
|
||||
|
||||
**Help**
|
||||
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).
|
||||
|
||||
cellxgene --help
|
||||
### Security
|
||||
|
||||
_For help with the scanpy engine_
|
||||
If you believe you have found a security issue, we would appreciate notification. Please send email to <security@chanzuckerberg.com>.
|
||||
|
||||
cellxgene scanpy --help
|
||||
# About
|
||||
### Core team
|
||||
|
||||
## Using your own data
|
||||
The current core team:
|
||||
|
||||
### Scanpy
|
||||
- Colin Megill, frontend & product design
|
||||
- Bruce Martin, software engineer
|
||||
- Sidney Bell, computational biologist
|
||||
- Lia Prins, designer
|
||||
- Severiano Badajoz, software engineer
|
||||
|
||||
To prepare your data you will need to format your data into AnnData format using scanpy and calculate PCA and nearest neighbors and save in h5ad format.
|
||||
We would also like to gratefully acknowledge contributions from past core team members:
|
||||
|
||||
1. [Load data into scanpy](https://scanpy.readthedocs.io/en/latest/api/index.html#reading)
|
||||
- Charlotte Weaver, software engineer
|
||||
|
||||
- Ensure that `obs`'s index is the cell names: `print(data.obs_names)` should show your cell indices. If it shows gene names, you may need to just call `data.transpose()`.
|
||||
### Inspiration
|
||||
|
||||
2. Calculate PCA
|
||||
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.
|
||||
|
||||
sc.pp.pca(data) ## sc is scanpy.api
|
||||
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
|
||||
|
||||
3. Calculate nearest neighbors (depending on layout algorithm)
|
||||
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.
|
||||
|
||||
```
|
||||
# For umap layout algorithm, you need to use the "umap" method for neighbors
|
||||
sc.pp.neighbors(data, method="umap", metric="euclidean", use_rep="X_pca")
|
||||
|
||||
# For tsne layout algorithm, you can use either "umap" or "gauss"; we recommend "gauss"
|
||||
sc.pp.neighbors(data, method="gauss", metric="euclidean", use_rep="X_pca")
|
||||
```
|
||||
|
||||
4. Save file
|
||||
|
||||
```
|
||||
# cellxgene requires file to be named data.h5ad
|
||||
data.write("data.h5ad")
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We warmly welcome contributions from the community. Please submit any bug reports and feature requests through github issues. Please submit any direct contributions via a branch + pull request.
|
||||
|
||||
## Inspiration and collaboration
|
||||
|
||||
We’ve been inspired by several other related efforts in this space, 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/), & many others; we hope to explore collaborations where useful.
|
||||
|
||||
## Help/Contact
|
||||
|
||||
Have questions, suggestions, or comments? You can contact us by joining [CZI Science Slack](https://cziscience.slack.com/messages/CCTA8DF1T) and posting in the #cellxgene channel. Please submit any feature requests or bugs as an issue in github. We'd love to hear from you!
|
||||
|
||||
## Reuse
|
||||
|
||||
This project was started with the sole goal of empowering the scientific community to explore and understand their data. As such, we whole-heartedly encourage other scientific tool builders to adopt the patterns, tools, and code from this project, and reach out to us with ideas or questions using Github Issues or Pull Requests. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
cellxgene is inspired by many innovative projects. We would like to specifically thank:
|
||||
|
||||
- Alex Wolf for the demo dataset.
|
||||
- Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for API inspiration.
|
||||
We are eager to explore integrations with other computational backends such as [Seurat](https://github.com/satijalab/seurat) or [Bioconductor](https://github.com/Bioconductor)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "cellxgene",
|
||||
"description": "An interactive explorer for single-cell transcriptomics data",
|
||||
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
||||
"logo": "https://cellxgene-example-data.czi.technology/favicon.png",
|
||||
"keywords": [
|
||||
"scientific",
|
||||
"visualization",
|
||||
"scrna-seq",
|
||||
"transcriptomics",
|
||||
"dataviz"
|
||||
],
|
||||
"stack": "container",
|
||||
"env": {
|
||||
"DATASET": {
|
||||
"description": "Link to dataset",
|
||||
"value": "https://cellxgene-example-data.czi.technology/pbmc3k.h5ad",
|
||||
"required": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
CELLXGENE_DIR=$(dirname $DIR)
|
||||
|
||||
cd $CELLXGENE_DIR
|
||||
|
||||
npm install --prefix client/ client
|
||||
npm run --prefix client build
|
||||
rm -rf server/app/web/static
|
||||
mkdir -p server/app/web/static/img
|
||||
cp client/build/index.html server/app/web/templates/
|
||||
cp -r client/build/static server/app/web/
|
||||
|
||||
cp client/build/favicon.png server/app/web/static/img
|
||||
cp client/build/service-worker.js server/app/web/static/js/
|
||||
@@ -1,16 +0,0 @@
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
CELLXGENE_DIR=$(dirname $DIR)
|
||||
|
||||
echo "Uninstalling cellxgene"
|
||||
yes | pip uninstall cellxgene
|
||||
echo "removing node_modules"
|
||||
rm -rf $CELLXGENE_DIR/client/node_modules
|
||||
echo "removing client_build"
|
||||
rm -rf $CELLXGENE_DIR/client/build
|
||||
echo "removing egg-info"
|
||||
rm -rf $CELLXGENE_DIR/cellxgene.egg-info
|
||||
echo "removing static files"
|
||||
rm -f $CELLXGENE_DIR/server/app/web/templates/index.html
|
||||
rm -rf $CELLXGENE_DIR/server/app/web/static
|
||||
echo "cellxgene cleanup complete"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
include ../common.mk
|
||||
|
||||
DATASET := $(if $(DATASET),$(DATASET),../example-dataset/pbmc3k.h5ad)
|
||||
ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../server/test/test_datasets/pbmc3k-annotations.csv)
|
||||
ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS))
|
||||
|
||||
# Packaging
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf node_modules
|
||||
|
||||
.PHONY: ci
|
||||
ci:
|
||||
npm ci client
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
npm install client
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
npm run build
|
||||
|
||||
|
||||
# Development convenience methods
|
||||
|
||||
.PHONY: server-requirements
|
||||
server-requirements:
|
||||
virtualenv -p python3 ../venv
|
||||
source ../venv/bin/activate && \
|
||||
pip install -r ../server/requirements-dev.txt && \
|
||||
yes | pip uninstall cellxgene || true && \
|
||||
pip install -e ..
|
||||
|
||||
.PHONY: start-frontend
|
||||
start-frontend:
|
||||
node server/development.js
|
||||
|
||||
.PHONY: start-server
|
||||
start-server:
|
||||
cellxgene launch -p $(CXG_SERVER_PORT) $(CXG_OPTIONS) $(DATASET)
|
||||
|
||||
.PHONY: backend-dev
|
||||
backend-dev: server-requirements
|
||||
source ../venv/bin/activate && $(MAKE) start-server
|
||||
|
||||
.PHONY: backend-dev-anno-ontology
|
||||
backend-dev-anno-ontology: server-requirements
|
||||
CXG_OPTIONS='--experimental-annotations-ontology' \
|
||||
$(MAKE) backend-dev
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
node node_modules/jest/bin/jest.js
|
||||
|
||||
.PHONY: e2e
|
||||
e2e:
|
||||
node node_modules/jest/bin/jest.js \
|
||||
--verbose false \
|
||||
--config __tests__/e2e/e2eJestConfig.json \
|
||||
e2e/e2e.test.js
|
||||
|
||||
.PHONY: e2e-annotations
|
||||
e2e-annotations:
|
||||
node node_modules/jest/bin/jest.js \
|
||||
--verbose false \
|
||||
--config __tests__/e2e/e2eJestConfig.json \
|
||||
e2e/e2eAnnotations.test.js
|
||||
|
||||
.PHONY: smoke-test
|
||||
smoke-test:
|
||||
start_server_and_test \
|
||||
'CXG_OPTIONS="--disable-annotations" $(MAKE) start-server' \
|
||||
$(CXG_SERVER_PORT) \
|
||||
'$(MAKE) e2e'
|
||||
|
||||
|
||||
.PHONY: smoke-test-annotations
|
||||
smoke-test-annotations:
|
||||
$(eval TMP_DIR := $(shell mktemp -d /tmp/cellxgene_XXXXXX))
|
||||
cp $(ANNOTATIONS) $(TMP_DIR)/ && \
|
||||
start_server_and_test \
|
||||
'CXG_OPTIONS="--annotations-file $(TMP_DIR)/$(ANNOTATIONS_FILENAME)" $(MAKE) start-server' \
|
||||
$(CXG_SERVER_PORT) \
|
||||
'$(MAKE) e2e-annotations'
|
||||
rm -rf $(TMP_DIR)
|
||||
|
||||
.PHONY: unit-test
|
||||
unit-test:
|
||||
node node_modules/jest/bin/jest.js \
|
||||
--testPathIgnorePatterns e2e
|
||||
|
||||
# pass remaining commands through to npm run
|
||||
%:
|
||||
npm run $(*)
|
||||
@@ -0,0 +1,196 @@
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
export const cellxgeneActions = (page, utils) => ({
|
||||
|
||||
async drag(testId, start, end, lasso = false) {
|
||||
const layout = await utils.waitByID(testId);
|
||||
const elBox = await layout.boxModel();
|
||||
const x1 = elBox.content[0].x + start.x;
|
||||
const x2 = elBox.content[0].x + end.x;
|
||||
const y1 = elBox.content[0].y + start.y;
|
||||
const y2 = elBox.content[0].y + end.y;
|
||||
await page.mouse.move(x1, y1);
|
||||
await page.mouse.down();
|
||||
if (lasso) {
|
||||
await page.mouse.move(x2, y1);
|
||||
await page.mouse.move(x2, y2);
|
||||
await page.mouse.move(x1, y2);
|
||||
await page.mouse.move(x1, y1);
|
||||
} else {
|
||||
await page.mouse.move(x2, y2);
|
||||
}
|
||||
await page.mouse.up();
|
||||
},
|
||||
|
||||
async clickOnCoordinate(testId, coord) {
|
||||
const layout = await utils.waitByID(testId);
|
||||
const elBox = await layout.boxModel();
|
||||
const x = elBox.content[0].x + coord.x;
|
||||
const y = elBox.content[0].y + coord.y;
|
||||
await page.mouse.click(x, y);
|
||||
},
|
||||
|
||||
async getAllHistograms(testclass, testIds) {
|
||||
const histTestIds = testIds.map(tid => `histogram-${tid}`);
|
||||
// these load asynchronously, so we need to wait for each histogram individually
|
||||
await utils.waitForAllByIds(histTestIds);
|
||||
const allHistograms = await utils.getAllByClass(testclass);
|
||||
return allHistograms.map(hist => hist.replace(/^histogram-/, ""));
|
||||
},
|
||||
|
||||
async getAllCategoriesAndCounts(category) {
|
||||
await utils.waitByClass("categorical-row");
|
||||
return page.$$eval(
|
||||
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
|
||||
rows => Object.fromEntries(rows.map(row => {
|
||||
const cat = row.querySelector("[data-testclass='categorical-value']").innerText;
|
||||
const count = row.querySelector("[data-testclass='categorical-value-count']").innerText;
|
||||
return [cat, count];
|
||||
}))
|
||||
);
|
||||
},
|
||||
|
||||
async cellSet(num) {
|
||||
await utils.clickOn(`cellset-button-${num}`);
|
||||
return utils.getOneElementInnerText(`[data-testid='cellset-count-${num}']`);
|
||||
},
|
||||
|
||||
async resetCategory(category) {
|
||||
const checkboxId = `${category}:category-select`;
|
||||
await utils.waitByID(checkboxId);
|
||||
const checkedPseudoclass = await page.$eval(
|
||||
`[data-testid='${checkboxId}']`,
|
||||
el => el.matches(":checked")
|
||||
);
|
||||
if (!checkedPseudoclass) await utils.clickOn(checkboxId);
|
||||
try {
|
||||
const categoryRow = await utils.waitByID(`${category}:category-expand`);
|
||||
const isExpanded = await categoryRow.$("[data-testclass='category-expand-is-expanded']");
|
||||
if (isExpanded) await utils.clickOn(`${category}:category-expand`);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async calcCoordinate(testId, xAsPercent, yAsPercent) {
|
||||
const el = await utils.waitByID(testId);
|
||||
const size = await el.boxModel();
|
||||
return {
|
||||
x: Math.floor(size.width * xAsPercent),
|
||||
y: Math.floor(size.height * yAsPercent)
|
||||
}
|
||||
},
|
||||
|
||||
async calcDragCoordinates(testId, coordinateAsPercent) {
|
||||
return {
|
||||
start: await this.calcCoordinate(testId, coordinateAsPercent.x1, coordinateAsPercent.y1),
|
||||
end: await this.calcCoordinate(testId, coordinateAsPercent.x2, coordinateAsPercent.y2)
|
||||
};
|
||||
},
|
||||
|
||||
async selectCategory(category, values, reset = true) {
|
||||
if (reset) await this.resetCategory(category);
|
||||
await utils.clickOn(`${category}:category-expand`);
|
||||
await utils.clickOn(`${category}:category-select`);
|
||||
for (const val of values) {
|
||||
await utils.clickOn(`categorical-value-select-${category}-${val}`);
|
||||
}
|
||||
},
|
||||
|
||||
async expandCategory(category) {
|
||||
const expand = await utils.waitByID(`${category}:category-expand`);
|
||||
const notExpanded = await expand.$("[data-testclass='category-expand-is-not-expanded']");
|
||||
if (notExpanded) await utils.clickOn(`${category}:category-expand`);
|
||||
},
|
||||
|
||||
async clip(min = 0, max = 100) {
|
||||
await utils.clickOn("visualization-settings");
|
||||
await utils.clearInputAndTypeInto("clip-min-input", min);
|
||||
await utils.clearInputAndTypeInto("clip-max-input", max);
|
||||
await utils.clickOn("clip-commit");
|
||||
},
|
||||
|
||||
async createCategory(categoryName) {
|
||||
await utils.clickOn("open-annotation-dialog");
|
||||
await utils.typeInto("new-category-name", categoryName);
|
||||
await utils.clickOn("submit-category");
|
||||
},
|
||||
|
||||
async renameCategory(oldCatgoryName, newCategoryName) {
|
||||
await utils.clickOn(`${oldCatgoryName}:see-actions`);
|
||||
await utils.clickOn(`${oldCatgoryName}:edit-category-mode`);
|
||||
await utils.clearInputAndTypeInto(`${oldCatgoryName}:edit-category-name-text`, newCategoryName);
|
||||
await utils.clickOn(`${oldCatgoryName}:submit-category-edit`);
|
||||
},
|
||||
|
||||
async deleteCategory(categoryName) {
|
||||
await utils.clickOn(`${categoryName}:see-actions`);
|
||||
await utils.clickOn(`${categoryName}:delete-category`);
|
||||
},
|
||||
|
||||
async createLabel(categoryName, labelName) {
|
||||
await utils.clickOn(`${categoryName}:see-actions`);
|
||||
await utils.clickOn(`${categoryName}:add-new-label-to-category`);
|
||||
await utils.typeInto(`${categoryName}:new-label-name`, labelName);
|
||||
await utils.clickOn(`${categoryName}:submit-label`);
|
||||
},
|
||||
|
||||
async deleteLabel(categoryName, labelName) {
|
||||
await this.expandCategory(categoryName);
|
||||
await utils.clickOn(`${categoryName}:${labelName}:see-actions`);
|
||||
await utils.clickOn( `${categoryName}:${labelName}:delete-label`);
|
||||
},
|
||||
|
||||
async renameLabel(categoryName, oldLabelName, newLabelName) {
|
||||
await this.expandCategory(categoryName);
|
||||
await utils.clickOn(`${categoryName}:${oldLabelName}:see-actions`);
|
||||
await utils.clickOn(`${categoryName}:${oldLabelName}:edit-label`);
|
||||
await utils.clearInputAndTypeInto(
|
||||
`${categoryName}:${oldLabelName}:edit-label-name`,
|
||||
newLabelName
|
||||
);
|
||||
await utils.clickOn(`${categoryName}:${oldLabelName}:submit-label-edit`);
|
||||
},
|
||||
|
||||
async addGeneToSearch(geneName) {
|
||||
await utils.typeInto("gene-search", geneName);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(
|
||||
`[data-testid='histogram-${geneName}']`
|
||||
);
|
||||
},
|
||||
|
||||
async subset(coordinatesAsPercent) {
|
||||
// In order to deselect the selection after the subset, make sure we have some clear part
|
||||
// of the scatterplot we can click on
|
||||
assert(coordinatesAsPercent.x2 < 0.99 || coordinatesAsPercent.y2 < 0.99);
|
||||
const lassoSelection = await this.calcDragCoordinates( "layout-graph", coordinatesAsPercent);
|
||||
await this.drag("layout-graph", lassoSelection.start, lassoSelection.end, true );
|
||||
await utils.clickOn("subset-button");
|
||||
const clearCoordinate = await this.calcCoordinate(
|
||||
"layout-graph",
|
||||
0.5,
|
||||
0.99
|
||||
);
|
||||
await this.clickOnCoordinate("layout-graph", clearCoordinate);
|
||||
},
|
||||
|
||||
async setSellSet(cellSet, cellSetNum) {
|
||||
for (const selection of cellSet.filter(sel => sel.kind === "categorical")) {
|
||||
await this.selectCategory(selection.metadata, selection.values, true);
|
||||
}
|
||||
await this.cellSet(cellSetNum);
|
||||
},
|
||||
|
||||
async runDiffExp(cellSet1, cellSet2) {
|
||||
await this.setSellSet(cellSet1, 1);
|
||||
await this.setSellSet(cellSet2, 2);
|
||||
await utils.clickOn("diffexp-button");
|
||||
},
|
||||
|
||||
async bulkAddGenes(geneNames) {
|
||||
await utils.clickOn("section-bulk-add");
|
||||
await utils.typeInto("input-bulk-add", geneNames.join(","));
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const jest_env = process.env.JEST_ENV;
|
||||
export const appPort = process.env.CXG_SERVER_PORT;
|
||||
export const appUrlBase = `http://localhost:${appPort}`;
|
||||
export const DEV = jest_env === "dev";
|
||||
export const DEBUG = jest_env === "debug";
|
||||
export const DATASET = "pbmc3k";
|
||||
|
||||
if (DEBUG) jest.setTimeout(100000);
|
||||
if (DEV) jest.setTimeout(10000);
|
||||
@@ -0,0 +1,147 @@
|
||||
export const datasets = {
|
||||
pbmc3k: {
|
||||
title: "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.1, y1: 0.25, x2: 0.7, y2: 0.75 },
|
||||
count: "1181"
|
||||
}
|
||||
],
|
||||
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: "1552"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
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",
|
||||
"CD14+ Monocytes": "0",
|
||||
"CD4 T cells": "0",
|
||||
"CD8 T cells": "0",
|
||||
"Dendritic cells": "0",
|
||||
"FCGR3A+ Monocytes": "0",
|
||||
Megakaryocytes: "15",
|
||||
"NK cells": "0"
|
||||
}
|
||||
},
|
||||
lasso: {
|
||||
"coordinates-as-percent": { x1: 0.25, y1: 0.05, x2: 0.75, y2: 0.55 },
|
||||
count: "329"
|
||||
}
|
||||
},
|
||||
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"
|
||||
}
|
||||
}
|
||||
},
|
||||
categoryLabel: {
|
||||
lasso: {
|
||||
"coordinates-as-percent": { x1: 0.05, y1: 0.3, x2: 0.5, y2: 0.5 },
|
||||
},
|
||||
newCount: {
|
||||
bySubsetConfig: {
|
||||
false: "600",
|
||||
true: "591"
|
||||
}
|
||||
}
|
||||
},
|
||||
annotationsFromFile: {
|
||||
count: {
|
||||
bySubsetConfig: {
|
||||
false: "1161",
|
||||
true: "856"
|
||||
}
|
||||
}
|
||||
},
|
||||
clip: {
|
||||
min: "30",
|
||||
max: "70",
|
||||
metadata: "n_genes",
|
||||
gene: "S100A8",
|
||||
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
|
||||
count: "392",
|
||||
"gene-cell-count": "421"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
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 { appUrlBase, DATASET } from "./config";
|
||||
import { setupTestBrowser } from "./testBrowser";
|
||||
import { datasets } from "./data";
|
||||
|
||||
let browser, page, utils, cxgActions;
|
||||
const data = datasets[DATASET];
|
||||
|
||||
beforeAll(async () => {
|
||||
const browserViewport = { width: 1280, height: 960 };
|
||||
[browser, page, utils, cxgActions] = await setupTestBrowser(browserViewport);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await page.goto(appUrlBase);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (browser !== undefined) browser.close();
|
||||
});
|
||||
|
||||
describe("did launch", () => {
|
||||
test("page launched", async () => {
|
||||
const element = await utils.getOneElementInnerHTML("[data-testid='header']");
|
||||
expect(element).toBe(data.title);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata loads", () => {
|
||||
test("categories and values from dataset appear", async () => {
|
||||
for (const label in data.categorical) {
|
||||
await utils.waitByID(`category-${label}`);
|
||||
const categoryName = await utils.getOneElementInnerText(`[data-testid="category-${label}"]`);
|
||||
expect(categoryName).toMatch(label);
|
||||
await utils.clickOn(`${label}:category-expand`);
|
||||
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", () => {
|
||||
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(`${cellset.metadata}:category-expand`);
|
||||
await utils.clickOn(`${cellset.metadata}:category-select`);
|
||||
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 histBrushableAreaId = `histogram-${cellset.metadata}-plot-brushable-area`;
|
||||
const coords = await cxgActions.calcDragCoordinates(
|
||||
histBrushableAreaId,
|
||||
cellset["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("gene entry", () => {
|
||||
test("search for single gene", async () => cxgActions.addGeneToSearch(data.genes.search));
|
||||
|
||||
test("bulk add genes", async () => {
|
||||
const testGenes = data.genes.bulkadd;
|
||||
await cxgActions.bulkAddGenes(testGenes);
|
||||
const allHistograms = await cxgActions.getAllHistograms("histogram-user-gene", testGenes);
|
||||
expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
|
||||
expect(allHistograms.length).toEqual(testGenes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("differential expression", () => {
|
||||
test("selects cells, saves them and performs diffexp", async () => {
|
||||
await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
||||
const allHistograms = await cxgActions.getAllHistograms(
|
||||
"histogram-diffexp",
|
||||
data.diffexp["gene-results"]
|
||||
);
|
||||
expect(allHistograms).toEqual(
|
||||
expect.arrayContaining(data.diffexp["gene-results"])
|
||||
);
|
||||
expect(allHistograms.length).toEqual(data.diffexp["gene-results"].length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subset", () => {
|
||||
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("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);
|
||||
});
|
||||
|
||||
test("undo selection appends the top diff exp genes to user defined genes", async () => {
|
||||
const userDefinedGenes = data.genes.bulkadd;
|
||||
const diffExpGenes = data.diffexp["gene-results"];
|
||||
await cxgActions.bulkAddGenes(userDefinedGenes);
|
||||
const userDefinedHistograms = await cxgActions.getAllHistograms("histogram-user-gene", userDefinedGenes);
|
||||
expect(userDefinedHistograms).toEqual(expect.arrayContaining(userDefinedGenes));
|
||||
await cxgActions.subset({x1: 0.15, y1: 0.10, x2: 0.98, y2: 0.98});
|
||||
await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
||||
const diffExpHistograms = await cxgActions.getAllHistograms("histogram-diffexp", diffExpGenes);
|
||||
expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes));
|
||||
await utils.clickOn("reset-subset-button");
|
||||
const expected = [].concat(userDefinedGenes, diffExpGenes);
|
||||
const userDefinedHistogramsAfterSubset = await cxgActions.getAllHistograms(
|
||||
"histogram-user-gene",
|
||||
expected
|
||||
);
|
||||
expect(userDefinedHistogramsAfterSubset).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
|
||||
test("subset selection appends the top diff exp genes to user defined genes", async () => {
|
||||
const userDefinedGenes = data.genes.bulkadd;
|
||||
const diffExpGenes = data.diffexp["gene-results"];
|
||||
await cxgActions.bulkAddGenes(userDefinedGenes);
|
||||
const userDefinedHistograms = await cxgActions.getAllHistograms("histogram-user-gene", userDefinedGenes);
|
||||
expect(userDefinedHistograms).toEqual(expect.arrayContaining(userDefinedGenes));
|
||||
await cxgActions.subset({x1: 0.15, y1: 0.10, x2: 0.98, y2: 0.98});
|
||||
await cxgActions.runDiffExp(data.diffexp.cellset1, data.diffexp.cellset2);
|
||||
const diffExpHistograms = await cxgActions.getAllHistograms("histogram-diffexp", diffExpGenes);
|
||||
expect(diffExpHistograms).toEqual(expect.arrayContaining(diffExpGenes));
|
||||
await cxgActions.subset({x1: 0.16, y1: 0.11, x2: 0.97, y2: 0.97});
|
||||
const expected = [].concat(userDefinedGenes, diffExpGenes);
|
||||
const userDefinedHistogramsAfterSubset = await cxgActions.getAllHistograms(
|
||||
"histogram-user-gene",
|
||||
expected
|
||||
);
|
||||
expect(userDefinedHistogramsAfterSubset).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
});
|
||||
|
||||
describe("scatter plot", () => {
|
||||
test("scatter plot appears", async () => {
|
||||
await cxgActions.bulkAddGenes(Object.values(data.scatter.genes));
|
||||
await utils.clickOn(`plot-x-${data.scatter.genes.x}`);
|
||||
await utils.clickOn(`plot-y-${data.scatter.genes.y}`);
|
||||
await utils.waitByID("scatterplot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipping", () => {
|
||||
test("clip continuous", async () => {
|
||||
await cxgActions.clip(data.clip.min, data.clip.max);
|
||||
const histBrushableAreaId = `histogram-${data.clip.metadata}-plot-brushable-area`;
|
||||
const coords = await cxgActions.calcDragCoordinates(
|
||||
histBrushableAreaId,
|
||||
data.clip["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(data.clip.count);
|
||||
});
|
||||
|
||||
test("clip gene", async () => {
|
||||
await utils.typeInto("gene-search", data.clip.gene);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(`[data-testid='histogram-${data.clip.gene}']`);
|
||||
await cxgActions.clip(data.clip.min, data.clip.max);
|
||||
const histBrushableAreaId = `histogram-${data.clip.gene}-plot-brushable-area`;
|
||||
const coords = await cxgActions.calcDragCoordinates(
|
||||
histBrushableAreaId,
|
||||
data.clip["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(histBrushableAreaId, coords.start, coords.end);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(data.clip["gene-cell-count"]);
|
||||
});
|
||||
});
|
||||
|
||||
// interact with UI elements just that they do not break
|
||||
describe("ui elements don't error", () => {
|
||||
test("color by", async () => {
|
||||
for (const label in data.categorical) {
|
||||
await utils.clickOn(`colorby-${label}`);
|
||||
}
|
||||
for (const label in data.continuous) {
|
||||
await utils.clickOn(`colorby-${label}`);
|
||||
}
|
||||
});
|
||||
|
||||
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);");
|
||||
});
|
||||
});
|
||||
|
||||
describe("centroid labels", () => {
|
||||
test("labels are created", async () => {
|
||||
await utils.clickOn("centroid-label-toggle");
|
||||
const labels = Object.keys(data.categorical);
|
||||
/* eslint-disable no-await-in-loop */
|
||||
// Toggle colorby for each category and check to see if labels are generated
|
||||
for (let i = 0, { length } = labels; i < length; i += 1) {
|
||||
const label = labels[i];
|
||||
await utils.clickOn(`colorby-${label}`);
|
||||
const generatedLabels = await utils.getAllByClass("centroid-label");
|
||||
// Number of labels generated should be equal to size of the object
|
||||
expect(generatedLabels).toHaveLength(
|
||||
Object.keys(data.categorical[label]).length
|
||||
);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
});
|
||||
});
|
||||
|
||||
describe("graph overlay", () => {
|
||||
test("transform centroids correctly", async () => {
|
||||
const category = Object.keys(data.categorical)[0];
|
||||
await utils.clickOn("centroid-label-toggle");
|
||||
await utils.clickOn(`colorby-${category}`);
|
||||
await utils.clickOn("mode-pan-zoom");
|
||||
const panCoords = await cxgActions.calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.pan["coordinates-as-percent"]
|
||||
);
|
||||
|
||||
const categoryValue = Object.keys(data.categorical[category])[0];
|
||||
const initialCoordinates = await utils.getElementCoordinates(
|
||||
`${categoryValue}-centroid-label`
|
||||
);
|
||||
await cxgActions.drag(
|
||||
"layout-graph",
|
||||
panCoords.start,
|
||||
panCoords.end,
|
||||
false
|
||||
);
|
||||
const terminalCoordinates = await utils.getElementCoordinates(
|
||||
`${categoryValue}-centroid-label`
|
||||
);
|
||||
expect(terminalCoordinates[0] - initialCoordinates[0]).toBeCloseTo(
|
||||
panCoords.end.x - panCoords.start.x
|
||||
);
|
||||
expect(terminalCoordinates[1] - initialCoordinates[1]).toBeCloseTo(
|
||||
panCoords.end.y - panCoords.start.y
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
Tests included in this file are specific to annotation features
|
||||
*/
|
||||
import { appUrlBase, DATASET } from "./config";
|
||||
import { setupTestBrowser } from "./testBrowser";
|
||||
import { datasets } from "./data";
|
||||
|
||||
let browser, page, utils, actions;
|
||||
const data = datasets[DATASET];
|
||||
|
||||
beforeAll(async () => {
|
||||
const browserViewport = {width: 1280, height: 960};
|
||||
[browser, page, utils, actions] = await setupTestBrowser(browserViewport);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (browser !== undefined) browser.close()
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{withSubset: true, tag: "subset"},
|
||||
{withSubset: false, tag: "whole"}
|
||||
])("annotations", (config) => {
|
||||
|
||||
const perTestCategoryName = "per-test-category";
|
||||
const perTestLabelName = "per-test-label";
|
||||
|
||||
beforeEach(async () => {
|
||||
await page.goto(appUrlBase);
|
||||
// wait for the page to load
|
||||
await utils.waitByClass("autosave-complete");
|
||||
// setup the test fixtures
|
||||
await actions.createCategory(perTestCategoryName);
|
||||
await actions.createLabel(perTestCategoryName, perTestLabelName);
|
||||
if (config.withSubset) await actions.subset({x1: 0.10, y1: 0.10, x2: 0.80, y2: 0.80});
|
||||
await utils.waitByClass("autosave-complete");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteCategoryIfExists(perTestCategoryName);
|
||||
await utils.waitByClass("autosave-complete");
|
||||
});
|
||||
|
||||
test("create a category", async () => {
|
||||
const categoryName = `category-created-${config.tag}`;
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
await actions.createCategory(categoryName);
|
||||
await assertCategoryExists(categoryName);
|
||||
});
|
||||
|
||||
test("delete a category", async () => {
|
||||
await actions.deleteCategory(perTestCategoryName);
|
||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||
});
|
||||
|
||||
test("rename a category", async () => {
|
||||
const newCategoryName = `cluster-for-real-${config.tag}`;
|
||||
await actions.renameCategory(perTestCategoryName, newCategoryName);
|
||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||
await assertCategoryExists(newCategoryName);
|
||||
});
|
||||
|
||||
test("create a label", async () => {
|
||||
const labelName = `new-label-${config.tag}`;
|
||||
await assertLabelDoesNotExist(perTestCategoryName, labelName);
|
||||
await actions.createLabel(perTestCategoryName, labelName);
|
||||
await assertLabelExists(perTestCategoryName, labelName);
|
||||
});
|
||||
|
||||
test("delete a label", async () => {
|
||||
await actions.deleteLabel(perTestCategoryName, perTestLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||
});
|
||||
|
||||
test("rename a label", async () => {
|
||||
const newLabelName = "my-cool-new-label";
|
||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||
await actions.renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||
});
|
||||
|
||||
test("check cell count for a label loaded from file", async () => {
|
||||
const categoryName = "cluster-test";
|
||||
const labelName = "four";
|
||||
await actions.expandCategory(categoryName);
|
||||
const result = await utils.waitByID(`categorical-value-count-${categoryName}-${labelName}`);
|
||||
expect(await result.evaluate(node => node.innerText)).toBe(
|
||||
data.annotationsFromFile.count.bySubsetConfig[config.withSubset]
|
||||
);
|
||||
});
|
||||
|
||||
test("assign cells to a label", async () => {
|
||||
await actions.expandCategory(perTestCategoryName);
|
||||
const lassoSelection = await actions.calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.categoryLabel.lasso["coordinates-as-percent"]
|
||||
);
|
||||
await actions.drag(
|
||||
"layout-graph",
|
||||
lassoSelection.start,
|
||||
lassoSelection.end,
|
||||
true
|
||||
);
|
||||
await utils.waitByID("lasso-element", {visible: true});
|
||||
await utils.clickOn(`${perTestCategoryName}:${perTestLabelName}:see-actions`);
|
||||
await utils.clickOn(`${perTestCategoryName}:${perTestLabelName}:add-current-selection-to-this-label`);
|
||||
const result = await utils.waitByID(`categorical-value-count-${perTestCategoryName}-${perTestLabelName}`);
|
||||
expect(await result.evaluate(node => node.innerText)).toBe(
|
||||
data.categoryLabel.newCount.bySubsetConfig[config.withSubset]
|
||||
);
|
||||
});
|
||||
|
||||
test("undo/redo category creation", async () => {
|
||||
const categoryName = `category-created-undo-${config.tag}`;
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
await actions.createCategory(categoryName);
|
||||
await assertCategoryExists(categoryName);
|
||||
await utils.clickOn("undo");
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
await utils.clickOn("redo");
|
||||
await assertCategoryExists(categoryName);
|
||||
});
|
||||
|
||||
test("undo/redo category deletion", async () => {
|
||||
const categoryName = `category-deleted-undo-${config.tag}`;
|
||||
await actions.createCategory(categoryName);
|
||||
await assertCategoryExists(categoryName);
|
||||
await actions.deleteCategory(categoryName);
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
await utils.clickOn("undo");
|
||||
await assertCategoryExists(categoryName);
|
||||
await utils.clickOn("redo");
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
});
|
||||
|
||||
test("undo/redo category rename", async () => {
|
||||
const newCategoryName = `category-renamed-undo-${config.tag}`;
|
||||
await assertCategoryDoesNotExist(newCategoryName);
|
||||
await actions.renameCategory(perTestCategoryName, newCategoryName);
|
||||
await assertCategoryExists(newCategoryName);
|
||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||
await utils.clickOn("undo");
|
||||
await assertCategoryExists(perTestCategoryName);
|
||||
await assertCategoryDoesNotExist(newCategoryName);
|
||||
await utils.clickOn("redo");
|
||||
await assertCategoryExists(newCategoryName);
|
||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||
});
|
||||
|
||||
test("undo/redo label creation", async () => {
|
||||
const labelName = `label-created-undo-${config.tag}`;
|
||||
await assertLabelDoesNotExist(perTestCategoryName, labelName);
|
||||
await actions.createLabel(perTestCategoryName, labelName);
|
||||
await assertLabelExists(perTestCategoryName, labelName);
|
||||
await utils.clickOn("undo");
|
||||
await assertLabelDoesNotExist(perTestCategoryName);
|
||||
await utils.clickOn("redo");
|
||||
await assertLabelExists(perTestCategoryName, labelName);
|
||||
});
|
||||
|
||||
test("undo/redo label deletion", async () => {
|
||||
await actions.deleteLabel(perTestCategoryName, perTestLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName);
|
||||
await utils.clickOn("undo");
|
||||
await assertLabelExists(perTestCategoryName, perTestLabelName);
|
||||
await utils.clickOn("redo");
|
||||
await assertLabelDoesNotExist(perTestCategoryName);
|
||||
});
|
||||
|
||||
test("undo/redo label rename", async () => {
|
||||
const newLabelName = `label-renamed-undo-${config.tag}`;
|
||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||
await actions.renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
|
||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||
await utils.clickOn("undo");
|
||||
await assertLabelExists(perTestCategoryName, perTestLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||
await utils.clickOn("redo");
|
||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||
});
|
||||
|
||||
async function assertCategoryExists(categoryName) {
|
||||
const handle = await utils.waitByID(`${categoryName}:category-expand`);
|
||||
const result = await handle.evaluate(node => node.innerText);
|
||||
// slice beginning and end of category name result to account for truncation of long names
|
||||
expect(result.slice(0, 10)).toBe(categoryName.slice(0, 10));
|
||||
expect(result.slice(-10)).toBe(categoryName.slice(-10));
|
||||
}
|
||||
|
||||
async function assertCategoryDoesNotExist(categoryName) {
|
||||
const result = await page.$(`[data-testid='${categoryName}:category-expand']`);
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
|
||||
async function assertLabelExists(categoryName, labelName) {
|
||||
const category = await utils.waitByID(`${categoryName}:category-expand`);
|
||||
expect(category).not.toBeNull();
|
||||
await actions.expandCategory(categoryName);
|
||||
const previous = await utils.waitByID(`categorical-value-${categoryName}-${labelName}`);
|
||||
expect(await previous.evaluate(node => node.innerText)).toBe(labelName);
|
||||
}
|
||||
|
||||
async function assertLabelDoesNotExist(categoryName, labelName) {
|
||||
await actions.expandCategory(categoryName);
|
||||
const result = await page.$(`[data-testid='categorical-value-${categoryName}-${labelName}']`);
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
|
||||
async function deleteCategoryIfExists(categoryName) {
|
||||
try {
|
||||
const category = await page.waitForSelector(
|
||||
`[data-testid='${categoryName}:category-expand']`,
|
||||
{timeout: 200}
|
||||
);
|
||||
if (category !== null) return await actions.deleteCategory(categoryName);
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
});
|
||||
@@ -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,69 @@
|
||||
export const puppeteerUtils = page => ({
|
||||
async waitByID(testId, props = {}) {
|
||||
return page.waitForSelector(`[data-testid='${testId}']`, props);
|
||||
},
|
||||
|
||||
async waitByClass(testClass, props = {}) {
|
||||
return page.waitForSelector(`[data-testclass='${testClass}']`, props);
|
||||
},
|
||||
|
||||
async waitForAllByIds(testIds) {
|
||||
await Promise.all(
|
||||
testIds.map(testId => page.waitForSelector(`[data-testid='${testId}']`))
|
||||
);
|
||||
},
|
||||
|
||||
async getAllByClass(testClass) {
|
||||
return page.$$eval(`[data-testclass=${testClass}]`, eles =>
|
||||
eles.map(ele => ele.dataset.testid)
|
||||
);
|
||||
},
|
||||
|
||||
async typeInto(testId, text) {
|
||||
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
|
||||
// only works for text without special characters
|
||||
await this.waitByID(testId);
|
||||
const selector = `[data-testid='${testId}']`;
|
||||
// type ahead can be annoying if you don't pause before you type
|
||||
await page.click(selector);
|
||||
await page.waitFor(200);
|
||||
await page.type(selector, text);
|
||||
},
|
||||
|
||||
async clearInputAndTypeInto(testId, text) {
|
||||
await this.waitByID(testId);
|
||||
const selector = `[data-testid='${testId}']`;
|
||||
// only works for text without special characters
|
||||
// type ahead can be annoying if you don't pause before you type
|
||||
await page.click(selector);
|
||||
await page.waitFor(200);
|
||||
// select all
|
||||
await page.click(selector, { clickCount: 3 });
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.type(selector, text);
|
||||
},
|
||||
|
||||
async clickOn(testid, options = {}) {
|
||||
await this.waitByID(testid);
|
||||
const click = await page.click(`[data-testid='${testid}']`, options);
|
||||
await page.waitFor(50);
|
||||
return click;
|
||||
},
|
||||
|
||||
async getOneElementInnerHTML(selector) {
|
||||
await page.waitForSelector(selector);
|
||||
return page.$eval(selector, el => el.innerHTML);
|
||||
},
|
||||
|
||||
async getOneElementInnerText(selector) {
|
||||
await page.waitForSelector(selector);
|
||||
return page.$eval(selector, el => el.innerText);
|
||||
},
|
||||
|
||||
async getElementCoordinates(testid) {
|
||||
return page.$eval(`[data-testid='${testid}']`, elem => {
|
||||
const { left, top } = elem.getBoundingClientRect();
|
||||
return [left, top];
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import puppeteer from "puppeteer";
|
||||
import { DEBUG, DEV } from "./config";
|
||||
import { puppeteerUtils } from "./puppeteerUtils";
|
||||
import { cellxgeneActions } from "./cellxgeneActions";
|
||||
|
||||
export async function setupTestBrowser(browserViewport) {
|
||||
const browserParams = DEV
|
||||
? { headless: false, slowMo: 5 }
|
||||
: DEBUG
|
||||
? { headless: false, slowMo: 100, devtools: true }
|
||||
: {};
|
||||
const browser = await puppeteer.launch(browserParams);
|
||||
const page = await browser.pages().then(pages => pages[0]);
|
||||
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}`);
|
||||
});
|
||||
const utils = puppeteerUtils(page);
|
||||
const cxgActions = cellxgeneActions(page, utils);
|
||||
return [browser, page, utils, cxgActions];
|
||||
}
|
||||
|
||||
@@ -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,96 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import calcCentroid from "../../src/util/centroid";
|
||||
|
||||
import quantile from "../../src/util/quantile";
|
||||
import * as Universe from "../../src/util/stateManager/universe";
|
||||
import * as World from "../../src/util/stateManager/world";
|
||||
import * as REST from "./stateManager/sampleResponses";
|
||||
import { ControlsHelpers as CH } from "../../src/util/stateManager";
|
||||
|
||||
describe("centroid", () => {
|
||||
let world;
|
||||
let categoricalSelection;
|
||||
beforeAll(() => {
|
||||
// Create world + universe
|
||||
let universe = Universe.createUniverseFromResponse(
|
||||
_.cloneDeep(REST.config),
|
||||
_.cloneDeep(REST.schema)
|
||||
);
|
||||
|
||||
universe = {
|
||||
...universe,
|
||||
...Universe.addObsAnnotations(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.annotationsObs)
|
||||
),
|
||||
...Universe.addVarAnnotations(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.annotationsVar)
|
||||
),
|
||||
...Universe.addObsLayout(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.layoutObs)
|
||||
)
|
||||
};
|
||||
world = World.createWorldFromEntireUniverse(universe);
|
||||
|
||||
// Create categorical selection from world
|
||||
categoricalSelection = CH.createCategoricalSelection(
|
||||
world,
|
||||
CH.selectableCategoryNames(world.schema, CH.maxCategoryItems(REST.config))
|
||||
);
|
||||
});
|
||||
|
||||
test("field4 (categorical obsAnnotation)", () => {
|
||||
const centroidResult = calcCentroid(
|
||||
world.obsAnnotations,
|
||||
world.obsLayout,
|
||||
"field4",
|
||||
["umap_0", "umap_1"],
|
||||
categoricalSelection,
|
||||
world.schema.annotations.obsByName
|
||||
);
|
||||
|
||||
// Check to see that a centroid has been calculated for every categorical value
|
||||
const keysAsArray = Array.from(centroidResult.keys());
|
||||
expect(keysAsArray).toEqual(
|
||||
expect.arrayContaining([83, true, "foo", 2.222222])
|
||||
);
|
||||
|
||||
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
||||
const expectedResult = [
|
||||
quantile([0.5], world.obsLayout.col("umap_0").asArray())[0],
|
||||
quantile([0.5], world.obsLayout.col("umap_1").asArray())[0]
|
||||
];
|
||||
|
||||
centroidResult.forEach(coordinate => {
|
||||
expect(coordinate).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test("field3 (boolean obsAnnotation", () => {
|
||||
const centroidResult = calcCentroid(
|
||||
world.obsAnnotations,
|
||||
world.obsLayout,
|
||||
"field3",
|
||||
["umap_0", "umap_1"],
|
||||
categoricalSelection,
|
||||
world.schema.annotations.obsByName
|
||||
);
|
||||
|
||||
// Check to see that a centroid has been calculated for every categorical value
|
||||
const keysAsArray = Array.from(centroidResult.keys());
|
||||
expect(keysAsArray).toEqual(expect.arrayContaining([false, true]));
|
||||
|
||||
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
||||
const expectedResult = [
|
||||
quantile([0.5], world.obsLayout.col("umap_0").asArray())[0],
|
||||
quantile([0.5], world.obsLayout.col("umap_1").asArray())[0]
|
||||
];
|
||||
|
||||
centroidResult.forEach(coordinate => {
|
||||
expect(coordinate).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,859 @@
|
||||
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("withColsFrom", () => {
|
||||
test("error conditions", () => {
|
||||
/*
|
||||
make sure we catch common errors:
|
||||
- duplicate column names
|
||||
- dimensionality difference
|
||||
*/
|
||||
const dfA = new Dataframe.Dataframe(
|
||||
[2, 3],
|
||||
[
|
||||
["red", "blue"],
|
||||
[true, false],
|
||||
[1, 0]
|
||||
],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
||||
);
|
||||
|
||||
/* different dimensionality should throw error */
|
||||
const dfB = new Dataframe.Dataframe(
|
||||
[3, 1],
|
||||
[["red", "blue", "green"]],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["colorsA"])
|
||||
);
|
||||
expect(() => dfA.withColsFrom(dfB)).toThrow(RangeError);
|
||||
|
||||
/* duplicate labels should throw an error */
|
||||
expect(() => dfA.withColsFrom(dfA)).toThrow(Error);
|
||||
});
|
||||
|
||||
test("simple", () => {
|
||||
/* simple test that it works as expected in common case */
|
||||
const dfEmpty = Dataframe.Dataframe.empty();
|
||||
const dfA = new Dataframe.Dataframe(
|
||||
[2, 1],
|
||||
[["red", "blue"]],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["colors"])
|
||||
);
|
||||
const dfB = new Dataframe.Dataframe(
|
||||
[2, 1],
|
||||
[[true, false]],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["bools"])
|
||||
);
|
||||
|
||||
const dfLikeA = dfEmpty.withColsFrom(dfA);
|
||||
expect(dfLikeA).toBeDefined();
|
||||
expect(dfLikeA.dims).toEqual(dfA.dims);
|
||||
expect(dfLikeA.colIndex.keys()).toEqual(dfA.colIndex.keys());
|
||||
expect(dfLikeA.rowIndex).toEqual(dfA.rowIndex);
|
||||
expect(dfLikeA.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||
expect(dfLikeA.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||
|
||||
const dfAlsoLikeA = dfA.withColsFrom(dfEmpty);
|
||||
expect(dfAlsoLikeA).toBeDefined();
|
||||
expect(dfAlsoLikeA.dims).toEqual(dfA.dims);
|
||||
expect(dfAlsoLikeA.colIndex.keys()).toEqual(dfA.colIndex.keys());
|
||||
expect(dfAlsoLikeA.rowIndex).toEqual(dfA.rowIndex);
|
||||
expect(dfAlsoLikeA.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||
expect(dfAlsoLikeA.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||
|
||||
const dfC = dfA.withColsFrom(dfB);
|
||||
expect(dfC).toBeDefined();
|
||||
expect(dfC.dims).toEqual([2, 2]);
|
||||
expect(dfC.colIndex.keys()).toEqual(["colors", "bools"]);
|
||||
expect(dfC.rowIndex).toEqual(dfA.rowIndex);
|
||||
expect(dfC.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||
expect(dfC.col("colors").asArray()).toEqual(["red", "blue"]);
|
||||
expect(dfC.col("bools").asArray()).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("column picking", () => {
|
||||
const dfEmpty = Dataframe.Dataframe.empty();
|
||||
const dfA = new Dataframe.Dataframe(
|
||||
[2, 1],
|
||||
[["red", "blue"]],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["colors"])
|
||||
);
|
||||
const dfB = new Dataframe.Dataframe(
|
||||
[2, 3],
|
||||
[
|
||||
["red", "blue"],
|
||||
[true, false],
|
||||
[1, 0]
|
||||
],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
||||
);
|
||||
|
||||
const dfX = dfEmpty.withColsFrom(dfB, ["colors", "bools"]);
|
||||
expect(dfX).toBeDefined();
|
||||
expect(dfX.dims).toEqual([2, 2]);
|
||||
expect(dfX.colIndex.keys()).toEqual(["colors", "bools"]);
|
||||
expect(dfX.rowIndex).toEqual(dfB.rowIndex);
|
||||
expect(dfX.icol(0).asArray()).toEqual(dfB.icol(0).asArray());
|
||||
|
||||
const dfY = dfA.withColsFrom(dfB, ["numbers"]);
|
||||
expect(dfY).toBeDefined();
|
||||
expect(dfY.dims).toEqual([2, 2]);
|
||||
expect(dfY.colIndex.keys()).toEqual(["colors", "numbers"]);
|
||||
expect(dfY.rowIndex).toEqual(dfA.rowIndex);
|
||||
expect(dfY.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||
|
||||
const dfZ = dfA.withColsFrom(dfEmpty, []);
|
||||
expect(dfZ).toBeDefined();
|
||||
expect(dfZ.dims).toEqual(dfA.dims);
|
||||
expect(dfZ.colIndex.keys()).toEqual(dfA.colIndex.keys());
|
||||
expect(dfZ.rowIndex).toEqual(dfA.rowIndex);
|
||||
expect(dfZ.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||
|
||||
expect(() => dfA.withColsFrom(dfB, ["bools", "colors"])).toThrow();
|
||||
});
|
||||
|
||||
test("column aliasing", () => {
|
||||
const dfA = new Dataframe.Dataframe(
|
||||
[2, 1],
|
||||
[["red", "blue"]],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["colors"])
|
||||
);
|
||||
const dfB = new Dataframe.Dataframe(
|
||||
[2, 3],
|
||||
[
|
||||
["red", "blue"],
|
||||
[true, false],
|
||||
[1, 0]
|
||||
],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
||||
);
|
||||
|
||||
const dfX = dfA.withColsFrom(dfB, { colors: "_colors", bools: "_bools" });
|
||||
expect(dfX).toBeDefined();
|
||||
expect(dfX.dims).toEqual([2, 3]);
|
||||
expect(dfX.colIndex.keys()).toEqual(["colors", "_colors", "_bools"]);
|
||||
expect(dfX.rowIndex).toEqual(dfA.rowIndex);
|
||||
expect(dfX.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||
expect(dfX.col("_colors").asArray()).toBe(dfB.col("colors").asArray());
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("columns", () => {
|
||||
const df = Dataframe.Dataframe.create(
|
||||
[3, 3],
|
||||
[new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)]
|
||||
);
|
||||
|
||||
expect(df).toBeDefined();
|
||||
expect(df.columns()).toHaveLength(3);
|
||||
expect(df.columns()[0]).toEqual(df.icol(0));
|
||||
expect(df.columns()[2]).toEqual(df.icol(2));
|
||||
});
|
||||
|
||||
test("renameCol", () => {
|
||||
const dfA = new Dataframe.Dataframe(
|
||||
[2, 2],
|
||||
[
|
||||
[true, false],
|
||||
[1, 0]
|
||||
],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["A", "B"])
|
||||
);
|
||||
const dfB = dfA.renameCol("B", "C");
|
||||
expect(dfA.colIndex.keys()).toEqual(["A", "B"]);
|
||||
expect(dfB.colIndex.keys()).toEqual(["A", "C"]);
|
||||
expect(dfA.dims).toMatchObject(dfB.dims);
|
||||
expect(dfA.columns()).toMatchObject(dfB.columns());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,93 @@
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
|
||||
describe("Dataframe column histogram", () => {
|
||||
test("categorical by categorical", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 3],
|
||||
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["name", "cat", "value"])
|
||||
);
|
||||
|
||||
const h1 = df.col("cat").histogram(df.col("name"));
|
||||
expect(h1).toMatchObject(
|
||||
new Map([
|
||||
["n1", new Map([["c1", 1]])],
|
||||
["n2", new Map([["c2", 1]])],
|
||||
["n3", new Map([["c3", 1]])]
|
||||
])
|
||||
);
|
||||
// memoized?
|
||||
expect(df.col("cat").histogram(df.col("name"))).toMatchObject(h1);
|
||||
});
|
||||
|
||||
test("continuous by categorical", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 3],
|
||||
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["name", "cat", "value"])
|
||||
);
|
||||
|
||||
const h1 = df.col("value").histogram(3, [0, 2], df.col("name"));
|
||||
expect(h1).toMatchObject(
|
||||
new Map([["n1", [1, 0, 0]], ["n2", [0, 1, 0]], ["n3", [0, 0, 1]]])
|
||||
);
|
||||
// memoized?
|
||||
expect(df.col("value").histogram(3, [0, 2], df.col("name"))).toMatchObject(
|
||||
h1
|
||||
);
|
||||
});
|
||||
|
||||
test("categorical", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 3],
|
||||
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["name", "cat", "value"])
|
||||
);
|
||||
|
||||
const h1 = df.col("cat").histogram();
|
||||
expect(h1).toMatchObject(new Map([["c1", 1], ["c2", 1], ["c3", 1]]));
|
||||
// memoized?
|
||||
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
|
||||
});
|
||||
|
||||
test("continuous", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 3],
|
||||
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["name", "cat", "value"])
|
||||
);
|
||||
|
||||
const h1 = df.col("value").histogram(3, [0, 2]);
|
||||
expect(h1).toMatchObject([1, 1, 1]);
|
||||
// memoized?
|
||||
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
|
||||
});
|
||||
|
||||
test("continuous thesholds correct", () => {
|
||||
const vals = [0, 1, 9, 10, 11, 20, 99, 100];
|
||||
const df = new Dataframe.Dataframe(
|
||||
[8, 2],
|
||||
[new Int32Array(vals), new Float32Array(vals)]
|
||||
);
|
||||
|
||||
expect(df.col(0).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
|
||||
expect(df.col(1).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
|
||||
expect(df.col(0).histogram(2, [0, 10])).toEqual([2, 2]);
|
||||
expect(df.col(0).histogram(10, [0, 100])).toEqual([
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,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,50 @@
|
||||
import { range, rangeFill, linspace } from "../../src/util/range";
|
||||
|
||||
describe("range", () => {
|
||||
test("no defaults", () => {
|
||||
expect(range(0, 3, 1)).toMatchObject([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("range(stop)", () => {
|
||||
expect(range(3)).toMatchObject([0, 1, 2]);
|
||||
expect(range(0)).toMatchObject([]);
|
||||
expect(range(1)).toMatchObject([0]);
|
||||
});
|
||||
|
||||
test("range(start,stop)", () => {
|
||||
expect(range(0, 0)).toMatchObject([]);
|
||||
expect(range(0, 2)).toMatchObject([0, 1]);
|
||||
expect(range(4, 8)).toMatchObject([4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
test("range(start, stop, step", () => {
|
||||
expect(range(4, 0, -1)).toMatchObject([4, 3, 2, 1]);
|
||||
expect(range(0, 4, 2)).toMatchObject([0, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rangefill", () => {
|
||||
test("rangeFill(arr)", () => {
|
||||
expect(rangeFill(new Int32Array(3))).toMatchObject(
|
||||
new Int32Array([0, 1, 2])
|
||||
);
|
||||
});
|
||||
test("rangeFill(arr, start)", () => {
|
||||
expect(rangeFill(new Int32Array(2), 1)).toMatchObject(
|
||||
new Int32Array([1, 2])
|
||||
);
|
||||
});
|
||||
test("rangeFill(arr, start, step)", () => {
|
||||
expect(rangeFill(new Int32Array(3), 2, -1)).toMatchObject(
|
||||
new Int32Array([2, 1, 0])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linspace", () => {
|
||||
test("linspace(arr, start, step)", () => {
|
||||
expect(linspace(0.0, 2.0, 5)).toMatchObject(
|
||||
[0.0, 0.5, 1.0, 1.5, 2.0]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
test controls helpers
|
||||
*/
|
||||
import { subsetAndResetGeneLists } from "../../../src/util/stateManager/controlsHelpers";
|
||||
import * as globals from "../../../src/globals";
|
||||
|
||||
describe("controls helpers", () => {
|
||||
|
||||
test("subsetAndResetGeneLists", () => {
|
||||
const geneList = [...Array(150).keys()].map(() =>
|
||||
Math.random().toString(36).substring(2, 6) // random string of 4 characters
|
||||
);
|
||||
const state = {
|
||||
userDefinedGenes: geneList.slice(0, 20),
|
||||
diffexpGenes: geneList.slice(20),
|
||||
};
|
||||
const [newUserDefinedGenes, newDiffExpGenes] = subsetAndResetGeneLists(state);
|
||||
expect(globals.maxUserDefinedGenes).toBeLessThan(globals.maxGenes);
|
||||
expect(geneList.length).toBeGreaterThan(globals.maxGenes);
|
||||
expect(newUserDefinedGenes).toHaveLength(globals.maxGenes);
|
||||
expect(newUserDefinedGenes).toStrictEqual(geneList.slice(0, globals.maxGenes));
|
||||
expect(newDiffExpGenes).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
test FBS encode/decode API
|
||||
*/
|
||||
import { Dataframe, KeyIndex } from "../../../src/util/dataframe";
|
||||
import {
|
||||
decodeMatrixFBS,
|
||||
encodeMatrixFBS
|
||||
} from "../../../src/util/stateManager/matrix";
|
||||
|
||||
describe("encode/decode", () => {
|
||||
test("round trip", () => {
|
||||
const columns = [
|
||||
["red", "green", "blue"],
|
||||
new Int32Array(3).fill(0),
|
||||
new Uint32Array(3).fill(1),
|
||||
new Float32Array(3).fill(2)
|
||||
];
|
||||
|
||||
const dfNoColIdx = new Dataframe([3, 4], columns);
|
||||
const dfA = decodeMatrixFBS(encodeMatrixFBS(dfNoColIdx));
|
||||
expect([dfA.nRows, dfA.nCols]).toEqual(dfNoColIdx.dims);
|
||||
expect(dfA.colIdx).toBeNull();
|
||||
expect(dfA.rowIdx).toBeNull();
|
||||
expect(dfA.columns).toEqual(columns);
|
||||
|
||||
const colIndex = new KeyIndex(["a", "b", "c", "d"]);
|
||||
const dfWithColIdx = new Dataframe([3, 4], columns, null, colIndex);
|
||||
const dfB = decodeMatrixFBS(encodeMatrixFBS(dfWithColIdx));
|
||||
expect([dfB.nRows, dfB.nCols]).toEqual(dfWithColIdx.dims);
|
||||
expect(dfB.colIdx).toEqual(colIndex.keys());
|
||||
expect(dfB.rowIdx).toBeNull();
|
||||
expect(dfB.columns).toEqual(columns);
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
import _ from "lodash";
|
||||
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
|
||||
|
||||
/*
|
||||
This is PRIVATE to keyvalcache and must be kept in sync with
|
||||
any changs ot that module. Need to Know - to enable error handling test
|
||||
*/
|
||||
const cachePrivateKey = "__kvcachekey__";
|
||||
|
||||
/*
|
||||
helper function - promisify setTimeout()
|
||||
*/
|
||||
function timeout(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("kvcache API", () => {
|
||||
/*
|
||||
test the happy path create/set/get API
|
||||
*/
|
||||
|
||||
test("simple create", () => {
|
||||
/* with defaults */
|
||||
const kvc = kvCache.create();
|
||||
expect(kvc).toBeDefined();
|
||||
expect(kvc).toEqual(expect.objectContaining({}));
|
||||
expect(kvCache.get(kvc, "test")).toBeUndefined();
|
||||
|
||||
/* with params */
|
||||
const kvc1 = kvCache.create(/* lowWatermark */ 99, /* minTTL */ 0);
|
||||
expect(kvc1).toBeDefined();
|
||||
expect(kvc1).toEqual(expect.objectContaining({}));
|
||||
});
|
||||
|
||||
test("set/get", () => {
|
||||
/*
|
||||
- check basic get/set functionality
|
||||
- check set does not mutate source cache
|
||||
*/
|
||||
const keyName = "foo";
|
||||
const kvc1 = kvCache.create();
|
||||
expect(kvc1).toBeDefined();
|
||||
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
|
||||
|
||||
const val2 = [2];
|
||||
const kvc2 = kvCache.set(kvc1, keyName, val2);
|
||||
expect(kvc2).toBeDefined();
|
||||
expect(kvc2).not.toBe(kvc1);
|
||||
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
|
||||
expect(kvCache.get(kvc2, keyName)).toBe(val2);
|
||||
|
||||
const val3 = [3];
|
||||
const kvc3 = kvCache.set(kvc2, keyName, val3);
|
||||
expect(kvc3).toBeDefined();
|
||||
expect(kvc3).not.toBe(kvc1);
|
||||
expect(kvc3).not.toBe(kvc2);
|
||||
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
|
||||
expect(kvCache.get(kvc2, keyName)).toBe(val2);
|
||||
expect(kvCache.get(kvc3, keyName)).toBe(val3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("common error handling", () => {
|
||||
/*
|
||||
Test common error handlers
|
||||
*/
|
||||
|
||||
test("set() protection from namespace pollution", () => {
|
||||
/*
|
||||
Test that set() will not allow use of the private cache key
|
||||
*/
|
||||
const kvc = kvCache.create();
|
||||
expect(() => {
|
||||
kvCache.set(kvc, cachePrivateKey, {});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test("create() does not accept bogus config", () => {
|
||||
expect(() => {
|
||||
kvCache.create([], {});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
kvCache.create(-99, 0);
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
kvCache.create(100, -1);
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
kvCache.create(1000, "foobar");
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
kvCache.create(null, 8);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("map", () => {
|
||||
/*
|
||||
Test kvCache.map() - create new cache that is a transformation of an
|
||||
existing cache
|
||||
*/
|
||||
test("map of empty cache", () => {
|
||||
const kvc = kvCache.create();
|
||||
const callback = jest.fn();
|
||||
const kvcMapped = kvCache.map(kvc, callback);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
expect(kvcMapped).toBeDefined();
|
||||
expect(kvcMapped).not.toBe(kvc); // immutable operation
|
||||
expect(kvcMapped).toEqual(kvc);
|
||||
});
|
||||
|
||||
test("map of non-empty cache", () => {
|
||||
const key = "aKey";
|
||||
const val = [0, 1, 2];
|
||||
let kvc = kvCache.create();
|
||||
kvc = kvCache.set(kvc, key, val);
|
||||
const mockCB = jest.fn().mockImplementation(v => [...v]);
|
||||
const kvcMapped = kvCache.map(kvc, mockCB);
|
||||
|
||||
expect(kvcMapped).toBeDefined();
|
||||
expect(kvcMapped).not.toBe(kvc); // immutable operation
|
||||
expect(_.isEqual(kvc, kvcMapped)).toBe(true);
|
||||
|
||||
expect(mockCB).toHaveBeenCalledTimes(1);
|
||||
expect(mockCB).toHaveBeenLastCalledWith(val, key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flush", () => {
|
||||
/*
|
||||
test various cache flush behavior
|
||||
*/
|
||||
test("flush - lowWatermark, disable minTTL", () => {
|
||||
/*
|
||||
verify lowWatermark functions correctly
|
||||
*/
|
||||
|
||||
// set lowWatermark to 2, set three times - only the final two
|
||||
// should remain.
|
||||
let kvc = kvCache.create(2, 0);
|
||||
["a", "b", "c"].forEach(k => {
|
||||
kvc = kvCache.set(kvc, k, []);
|
||||
});
|
||||
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
expect(kvc).toEqual(
|
||||
expect.not.objectContaining({
|
||||
a: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("flush - minTTL, disable lowWatermark", async () => {
|
||||
/*
|
||||
verify minTTL functions correctly
|
||||
*/
|
||||
|
||||
// set minTTL to 1 ms
|
||||
let kvc = kvCache.create(0, 10);
|
||||
kvc = kvCache.set(kvc, "a", []);
|
||||
await timeout(20);
|
||||
["b", "c"].forEach(k => {
|
||||
kvc = kvCache.set(kvc, k, []);
|
||||
});
|
||||
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
expect(kvc).toEqual(
|
||||
expect.not.objectContaining({
|
||||
a: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("flush - minTTL and lowWatermark", async () => {
|
||||
/*
|
||||
verify minTTL functions correctly
|
||||
*/
|
||||
|
||||
// set lowwatermark to 3, minTTL to 1 ms
|
||||
let kvc = kvCache.create(3, 10);
|
||||
kvc = kvCache.set(kvc, "a", []);
|
||||
// delay
|
||||
await timeout(20);
|
||||
["b", "c"].forEach(k => {
|
||||
kvc = kvCache.set(kvc, k, []);
|
||||
});
|
||||
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
a: expect.arrayContaining([]),
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
|
||||
kvc = kvCache.set(kvc, "d", []);
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([]),
|
||||
d: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
expect(kvc).toEqual(
|
||||
expect.not.objectContaining({
|
||||
a: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("manual flush", async () => {
|
||||
let kvc = kvCache.create(1, 10);
|
||||
["a", "b", "c", "d"].forEach(k => {
|
||||
kvc = kvCache.set(kvc, k, []);
|
||||
});
|
||||
|
||||
// Before TTL has expired, should have all values in cache.
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
a: expect.arrayContaining([]),
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
|
||||
// let TTL expire
|
||||
await timeout(10);
|
||||
|
||||
// manually flush
|
||||
const postFlushKvc = kvCache.flush(kvc);
|
||||
expect(postFlushKvc).toBeDefined();
|
||||
expect(postFlushKvc).not.toBe(kvc);
|
||||
expect(postFlushKvc).toEqual(
|
||||
expect.objectContaining({
|
||||
d: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
/* 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.
|
||||
@@ -32,33 +34,43 @@ const aSchemaResponse = {
|
||||
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
|
||||
}
|
||||
]
|
||||
obs: {
|
||||
index: "name",
|
||||
columns: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "field1", type: "int32" },
|
||||
{ name: "field2", type: "float32" },
|
||||
{ name: "field3", type: "boolean" },
|
||||
{
|
||||
name: "field4",
|
||||
type: "categorical",
|
||||
categories: field4Categories
|
||||
}
|
||||
]
|
||||
},
|
||||
var: {
|
||||
index: "name",
|
||||
columns: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "fieldA", type: "int32" },
|
||||
{ name: "fieldB", type: "float32" },
|
||||
{ name: "fieldC", type: "boolean" },
|
||||
{
|
||||
name: "fieldD",
|
||||
type: "categorical",
|
||||
categories: fieldDCategories
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
obs: [{ name: "umap", type: "float32", dims: ["umap_0", "umap_1"] }],
|
||||
var: []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const anAnnotationsObsResponse = {
|
||||
const anAnnotationsObsJSONResponse = {
|
||||
names: ["name", "field1", "field2", "field3", "field4"],
|
||||
data: _()
|
||||
.range(nObs)
|
||||
@@ -73,7 +85,7 @@ const anAnnotationsObsResponse = {
|
||||
.value()
|
||||
};
|
||||
|
||||
const anAnnotationsVarResponse = {
|
||||
const anAnnotationsVarJSONResponse = {
|
||||
names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"],
|
||||
data: _()
|
||||
.range(nVar)
|
||||
@@ -88,15 +100,88 @@ const anAnnotationsVarResponse = {
|
||||
.value()
|
||||
};
|
||||
|
||||
const aLayoutResponse = {
|
||||
layout: {
|
||||
ndims: 2,
|
||||
coordinates: _()
|
||||
.range(nObs)
|
||||
.map(idx => [idx, Math.random(), Math.random()])
|
||||
.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) {
|
||||
/*
|
||||
IMPORTANT: this is not a general purpose encoder. in particular,
|
||||
it doesn't correctly handle all column index types, nor does it
|
||||
handle all column typedarray types.
|
||||
|
||||
encodeMatrixFBS in matrix.py is more general. This is used only
|
||||
as a testing santity check (alt implementation).
|
||||
*/
|
||||
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())
|
||||
];
|
||||
return encodeMatrix(coords, ["umap_0", "umap_1"]);
|
||||
})();
|
||||
|
||||
const aDataObsResponse = {
|
||||
var: [2, 4, 29],
|
||||
@@ -107,10 +192,10 @@ const aDataObsResponse = {
|
||||
};
|
||||
|
||||
export {
|
||||
aLayoutResponse as layoutObs,
|
||||
aLayoutFBSResponse as layoutObs,
|
||||
aDataObsResponse as dataObs,
|
||||
anAnnotationsVarResponse as annotationsVar,
|
||||
anAnnotationsObsResponse as annotationsObs,
|
||||
anAnnotationsVarFBSResponse as annotationsVar,
|
||||
anAnnotationsObsFBSResponse as annotationsObs,
|
||||
aSchemaResponse as schema,
|
||||
aConfigResponse as config
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import _ from "lodash";
|
||||
import * as Universe from "../../../src/util/stateManager/universe";
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
import * as REST from "./sampleResponses";
|
||||
|
||||
describe("createUniverseFromRestV02Response", () => {
|
||||
describe("createUniverseFromResponse", () => {
|
||||
/*
|
||||
test createUniverseFromRestV02Response - this function converts
|
||||
test createUniverseFromResponse - this function converts
|
||||
a set of REST 0.2 responses into a "new" Universe.
|
||||
|
||||
createUniverseFromRestV02Response(
|
||||
createUniverseFromResponse(
|
||||
configResponse,
|
||||
schemaResponse,
|
||||
annotationsObsResponse,
|
||||
@@ -30,92 +30,63 @@ describe("createUniverseFromRestV02Response", () => {
|
||||
create a universe from sample data nad validate its shape & contents
|
||||
*/
|
||||
const { nObs, nVar } = REST.schema.schema.dataframe;
|
||||
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
let universe = Universe.createUniverseFromResponse(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
REST.schema
|
||||
);
|
||||
|
||||
expect(universe).toBeDefined();
|
||||
expect(universe).toMatchObject(
|
||||
expect.objectContaining({
|
||||
api: "0.2",
|
||||
nObs,
|
||||
nVar,
|
||||
schema: REST.schema.schema,
|
||||
obsAnnotations: expect.any(Array),
|
||||
varAnnotations: expect.any(Array),
|
||||
obsNameToIndexMap: expect.any(Object),
|
||||
varNameToIndexMap: expect.any(Object),
|
||||
obsLayout: expect.objectContaining({
|
||||
X: expect.any(Float32Array),
|
||||
Y: expect.any(Float32Array)
|
||||
}),
|
||||
varDataCache: expect.any(Object)
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe)
|
||||
})
|
||||
);
|
||||
|
||||
expect(universe.obsAnnotations).toHaveLength(nObs);
|
||||
expect(_.keys(universe.obsNameToIndexMap)).toHaveLength(nObs);
|
||||
expect(universe.obsLayout.X).toHaveLength(nObs);
|
||||
expect(universe.obsLayout.Y).toHaveLength(nObs);
|
||||
expect(universe.varAnnotations).toHaveLength(nVar);
|
||||
expect(_.keys(universe.varNameToIndexMap)).toHaveLength(nVar);
|
||||
});
|
||||
});
|
||||
universe = {
|
||||
...universe,
|
||||
...Universe.addObsAnnotations(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.annotationsObs)
|
||||
),
|
||||
...Universe.addVarAnnotations(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.annotationsVar)
|
||||
),
|
||||
...Universe.addObsLayout(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.layoutObs)
|
||||
)
|
||||
};
|
||||
|
||||
describe("convertExpressionRESTv02ToObject", () => {
|
||||
/*
|
||||
test convertExpressionRESTv02ToObject
|
||||
|
||||
convertExpressionRESTv02ToObject(
|
||||
universe,
|
||||
response) --> { geneName: Float32Array, geneName: Float32Array, ... }
|
||||
|
||||
reponse is a /data/obs response:
|
||||
{
|
||||
var: [ varIndices fetched ],
|
||||
obs: [
|
||||
[ obsIndex, evalue, ... ],
|
||||
...
|
||||
]
|
||||
}
|
||||
*/
|
||||
test("create from response data", () => {
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
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)
|
||||
})
|
||||
);
|
||||
const expression = Universe.convertExpressionRESTv02ToObject(
|
||||
universe,
|
||||
REST.dataObs
|
||||
);
|
||||
|
||||
/* Check that the expected keys are present */
|
||||
const expectedGeneNames = _.map(
|
||||
REST.dataObs.var,
|
||||
v => REST.annotationsVar.data[v][5]
|
||||
);
|
||||
expect(Object.keys(expression)).toEqual(
|
||||
expect.arrayContaining(expectedGeneNames)
|
||||
expect(universe.obsAnnotations.dims).toEqual([
|
||||
nObs,
|
||||
REST.schema.schema.annotations.obs.columns.length
|
||||
]);
|
||||
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
|
||||
expect(universe.obsLayout.colIndex.keys()).toEqual(
|
||||
universe.schema.layout.obs[0].dims
|
||||
);
|
||||
|
||||
const expectedExpressionValues = _.map(
|
||||
_.unzip(REST.dataObs.obs),
|
||||
a => new Float32Array(a)
|
||||
);
|
||||
|
||||
_.forEach(REST.dataObs.var, (varIdx, idx) => {
|
||||
const varName = universe.varAnnotations[varIdx].name;
|
||||
expect(varName).toBeDefined();
|
||||
expect(varIdx).toBe(universe.varNameToIndexMap[varName]);
|
||||
expect(expression[varName]).toEqual(expectedExpressionValues[idx + 1]);
|
||||
});
|
||||
expect(universe.varAnnotations.dims).toEqual([
|
||||
nVar,
|
||||
REST.schema.schema.annotations.var.columns.length
|
||||
]);
|
||||
expect(universe.varData.isEmpty()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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";
|
||||
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
|
||||
|
||||
/*
|
||||
Helper - creates universe, world, corssfilter and dimensionMap from
|
||||
@@ -16,36 +17,51 @@ the default REST test response.
|
||||
const defaultBigBang = () => {
|
||||
/* create unverse, world, crossfilter and dimensionMap */
|
||||
/* create universe */
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
let universe = Universe.createUniverseFromResponse(
|
||||
_.cloneDeep(REST.config),
|
||||
_.cloneDeep(REST.schema)
|
||||
);
|
||||
|
||||
universe = {
|
||||
...universe,
|
||||
...Universe.addObsAnnotations(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.annotationsObs)
|
||||
),
|
||||
...Universe.addVarAnnotations(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.annotationsVar)
|
||||
),
|
||||
...Universe.addObsLayout(
|
||||
universe,
|
||||
Universe.matrixFBSToDataframe(REST.layoutObs)
|
||||
)
|
||||
};
|
||||
|
||||
/* create world */
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
/* create crossfilter */
|
||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||
/* create dimension map */
|
||||
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
||||
const crossfilter = World.createObsDimensions(
|
||||
new Crossfilter(world.obsAnnotations),
|
||||
world,
|
||||
REST.schema.schema.layout.obs[0].dims
|
||||
);
|
||||
|
||||
return {
|
||||
universe,
|
||||
world,
|
||||
crossfilter,
|
||||
dimensionMap
|
||||
crossfilter
|
||||
};
|
||||
};
|
||||
|
||||
describe("createWorldFromEntireUniverse", () => {
|
||||
test("create from REST sample", () => {
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
const universe = Universe.createUniverseFromResponse(
|
||||
_.cloneDeep(REST.config),
|
||||
_.cloneDeep(REST.schema),
|
||||
Universe.matrixFBSToDataframe(_.cloneDeep(REST.annotationsObs)),
|
||||
Universe.matrixFBSToDataframe(_.cloneDeep(REST.annotationsVar)),
|
||||
Universe.matrixFBSToDataframe(_.cloneDeep(REST.layoutObs))
|
||||
);
|
||||
expect(universe).toBeDefined();
|
||||
|
||||
@@ -54,31 +70,18 @@ describe("createWorldFromEntireUniverse", () => {
|
||||
|
||||
expect(world).toMatchObject(
|
||||
expect.objectContaining({
|
||||
api: "0.2",
|
||||
nObs: universe.nObs,
|
||||
nVar: universe.nVar,
|
||||
schema: universe.schema,
|
||||
obsAnnotations: universe.obsAnnotations,
|
||||
varAnnotations: universe.varAnnotations,
|
||||
obsLayout: universe.obsLayout,
|
||||
|
||||
summary: expect.objectContaining({
|
||||
obs: _(REST.schema.schema.annotations.obs)
|
||||
.filter(v => v.name !== "name")
|
||||
.keyBy("name")
|
||||
.mapValues(() => expect.any(Object))
|
||||
.value(),
|
||||
var: _(REST.schema.schema.annotations.var)
|
||||
.filter(v => v.name !== "name")
|
||||
.keyBy("name")
|
||||
.mapValues(() => expect.any(Object))
|
||||
.value()
|
||||
}),
|
||||
|
||||
varDataCache: expect.any(Object),
|
||||
|
||||
obsIndex: null, // null indicating full universe
|
||||
obsBackIndex: null
|
||||
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)
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -89,73 +92,70 @@ describe("createWorldFromCurrentSelection", () => {
|
||||
const {
|
||||
universe,
|
||||
world: originalWorld,
|
||||
crossfilter,
|
||||
dimensionMap
|
||||
crossfilter: originalCrossfilter
|
||||
} = defaultBigBang();
|
||||
|
||||
/* mock a selection */
|
||||
dimensionMap[obsAnnoDimensionName("field1")].filterRange([0, 5]);
|
||||
dimensionMap[obsAnnoDimensionName("field3")].filterExact(false);
|
||||
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.createWorldFromCurrentSelection(
|
||||
const world = World.createWorldBySelection(
|
||||
universe,
|
||||
originalWorld,
|
||||
crossfilter
|
||||
);
|
||||
expect(world).toBeDefined();
|
||||
expect(world.nObs).toEqual(crossfilter.countFiltered());
|
||||
expect(world.nObs).toEqual(crossfilter.countSelected());
|
||||
|
||||
/*
|
||||
calculate expected values and match against result
|
||||
*/
|
||||
|
||||
/* matchFilter must match the dimension filters above */
|
||||
const matchFilter = val => val.field1 >= 0 && val.field1 < 5 && !val.field3;
|
||||
const universeIndices = _()
|
||||
.range(universe.nObs)
|
||||
.filter(idx => matchFilter(universe.obsAnnotations[idx]))
|
||||
.value();
|
||||
|
||||
const expected = {
|
||||
nObs: universeIndices.length,
|
||||
obsAnnotations: _.map(universeIndices, i => universe.obsAnnotations[i]),
|
||||
obsLayout: {
|
||||
X: new Float32Array(
|
||||
_.map(universeIndices, i => universe.obsLayout.X[i])
|
||||
),
|
||||
Y: new Float32Array(
|
||||
_.map(universeIndices, i => universe.obsLayout.Y[i])
|
||||
)
|
||||
},
|
||||
obsBackIndex: _.transform(
|
||||
universeIndices,
|
||||
(result, univIdx, worldIdx) => {
|
||||
result[univIdx] = worldIdx;
|
||||
},
|
||||
new Uint32Array(universe.nObs).fill(-1)
|
||||
),
|
||||
obsIndex: new Uint32Array(universeIndices)
|
||||
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({
|
||||
api: "0.2",
|
||||
nObs: expected.nObs,
|
||||
nObs: matchingIndices.length,
|
||||
nVar: universe.nVar,
|
||||
schema: universe.schema,
|
||||
obsAnnotations: expected.obsAnnotations,
|
||||
varAnnotations: universe.varAnnotations,
|
||||
obsLayout: expected.obsLayout,
|
||||
summary: {
|
||||
obs: expect.any(Object) /* we could do better! */,
|
||||
var: expect.any(Object) /* we could do better! */
|
||||
},
|
||||
varDataCache: expect.any(Object),
|
||||
obsIndex: expected.obsIndex,
|
||||
obsBackIndex: expected.obsBackIndex
|
||||
clipQuantiles: { min: 0, max: 1 },
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
unclipped: {
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe)
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(world.obsAnnotations.rowIndex.keys()).toEqual(
|
||||
new Int32Array(matchingIndices)
|
||||
);
|
||||
expect(world.obsAnnotations.colIndex.keys()).toEqual(
|
||||
universe.obsAnnotations.colIndex.keys()
|
||||
);
|
||||
expect(world.obsLayout.rowIndex.keys()).toEqual(
|
||||
new Int32Array(matchingIndices)
|
||||
);
|
||||
expect(world.obsLayout.colIndex.keys()).toEqual(
|
||||
world.schema.layout.obs[0].dims
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,87 +167,36 @@ describe("createObsDimensionMap", () => {
|
||||
- check that dimension typing is sane
|
||||
*/
|
||||
|
||||
const { dimensionMap } = defaultBigBang();
|
||||
|
||||
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name");
|
||||
expect(dimensionMap).toBeDefined();
|
||||
REST.annotationsObs.names.forEach(name => {
|
||||
const dim = dimensionMap[obsAnnoDimensionName(name)];
|
||||
if (name === "name") {
|
||||
const { crossfilter } = defaultBigBang();
|
||||
const annotationNames = _.map(
|
||||
REST.schema.schema.annotations.obs.columns,
|
||||
c => c.name
|
||||
);
|
||||
const obsIndexColName = REST.schema.schema.annotations.obs.index;
|
||||
const schemaByObsName = _.keyBy(
|
||||
REST.schema.schema.annotations.obs.columns,
|
||||
"name"
|
||||
);
|
||||
expect(crossfilter).toBeDefined();
|
||||
annotationNames.forEach(name => {
|
||||
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
|
||||
if (name === obsIndexColName) {
|
||||
expect(dim).toBeUndefined();
|
||||
} else {
|
||||
const { type } = schemaByObsName[name];
|
||||
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||
expect(dim).toBeInstanceOf(Crossfilter.EnumDimension);
|
||||
expect(dim.dim).toBeInstanceOf(DimTypes.enum);
|
||||
} else {
|
||||
expect(dim).toBeInstanceOf(Crossfilter.ScalarDimension);
|
||||
expect(dim.dim).toBeInstanceOf(DimTypes.scalar);
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(dimensionMap[layoutDimensionName("X")]).toBeInstanceOf(
|
||||
Crossfilter.ScalarDimension
|
||||
);
|
||||
expect(dimensionMap[layoutDimensionName("Y")]).toBeInstanceOf(
|
||||
Crossfilter.ScalarDimension
|
||||
);
|
||||
expect(
|
||||
crossfilter.dimensions[layoutDimensionName("XY")].dim
|
||||
).toBeInstanceOf(DimTypes.spatial);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subsetVarData", () => {
|
||||
test("when world eq universe", () => {
|
||||
const { universe, world } = defaultBigBang();
|
||||
/* create a mock varData array for subsetting */
|
||||
const sourceVarData = new Float32Array(universe.nObs);
|
||||
|
||||
/* expect literally the same object back */
|
||||
const result = World.subsetVarData(world, universe, sourceVarData);
|
||||
expect(result).toBe(sourceVarData);
|
||||
});
|
||||
|
||||
test("when world neq universe", () => {
|
||||
const { universe, world, crossfilter, dimensionMap } = defaultBigBang();
|
||||
/* create a mock varData array for subsetting */
|
||||
const sourceVarData = Float32Array.from(_.range(universe.nObs));
|
||||
|
||||
/* mock a selection */
|
||||
dimensionMap[obsAnnoDimensionName("field1")].filterRange([0, 5]);
|
||||
dimensionMap[obsAnnoDimensionName("field3")].filterExact(false);
|
||||
|
||||
/* create the world from the selection */
|
||||
const newWorld = World.createWorldFromCurrentSelection(
|
||||
universe,
|
||||
world,
|
||||
crossfilter
|
||||
);
|
||||
expect(newWorld.obsIndex).toMatchObject(new Uint32Array([0, 2]));
|
||||
|
||||
/* expect a subset */
|
||||
const result = World.subsetVarData(newWorld, universe, sourceVarData);
|
||||
expect(result).not.toBe(sourceVarData);
|
||||
expect(result).toHaveLength(newWorld.nObs);
|
||||
/* check that we have expected source var content */
|
||||
expect(result).toMatchObject(new Float32Array([0, 2]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVarDimension", () => {
|
||||
/* create default universe */
|
||||
const { world, crossfilter } = defaultBigBang();
|
||||
/* create a mock var data cache */
|
||||
const varDataCache = kvCache.set(
|
||||
kvCache.create(),
|
||||
"GENE",
|
||||
Float32Array.from(_.range(world.nObs))
|
||||
);
|
||||
const result = World.createVarDimension(
|
||||
world,
|
||||
varDataCache,
|
||||
crossfilter,
|
||||
"GENE"
|
||||
);
|
||||
expect(result).toBeInstanceOf(Crossfilter.ScalarDimension);
|
||||
});
|
||||
|
||||
describe("worldEqUniverse", () => {
|
||||
const { universe, world } = defaultBigBang();
|
||||
const result = World.worldEqUniverse(world, universe);
|
||||
|
||||
@@ -118,16 +118,16 @@ describe("selectionCount", () => {
|
||||
const dim2 = ba.allocDimension();
|
||||
expect(dim2).toBeDefined();
|
||||
|
||||
expect(ba.selectionCount).toEqual(0);
|
||||
expect(ba.selectionCount()).toEqual(0);
|
||||
ba.selectAll(dim1);
|
||||
expect(ba.selectionCount).toEqual(0);
|
||||
expect(ba.selectionCount()).toEqual(0);
|
||||
ba.selectAll(dim2);
|
||||
expect(ba.selectionCount).toEqual(defaultTestLength);
|
||||
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());
|
||||
expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1);
|
||||
expect(ba.selectionCount()).toEqual(ba.countAllOnes());
|
||||
}
|
||||
|
||||
ba.freeDimension(dim1);
|
||||
@@ -183,3 +183,24 @@ describe("fillBySelection", () => {
|
||||
expect(arr).toEqual(truth);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wide bitarray", () => {
|
||||
test.each([9, 30, 31, 32, 33, 54, 63, 64, 65, 127, 128, 129])(
|
||||
"more than %d dimensions",
|
||||
ndim => {
|
||||
/* ensure we move across the uint boundary correctly */
|
||||
|
||||
const ba = new BitArray(defaultTestLength);
|
||||
expect(ba).toBeDefined();
|
||||
|
||||
for (let i = 0; i < ndim; i += 1) {
|
||||
expect(ba.allocDimension()).toEqual(i);
|
||||
}
|
||||
ba.freeDimension(0);
|
||||
expect(ba.allocDimension()).toEqual(0);
|
||||
|
||||
ba.freeDimension(ndim - 1);
|
||||
expect(ba.allocDimension()).toEqual(ndim - 1);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import _ from "lodash";
|
||||
|
||||
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);
|
||||
|
||||
const p5 = p2.renameDimension("quantity", "Quantity");
|
||||
expect(p5).not.toBe(p2);
|
||||
});
|
||||
|
||||
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]],
|
||||
[true, true, true, true, true, false, true, true, true, true, true, true]
|
||||
],
|
||||
[
|
||||
[[0, 0], [0, 0.5], [0.5, 0.5], [0.5, 0]],
|
||||
[true, true, true, true, false, false, false, true, true, true, false, false]
|
||||
]
|
||||
])("within-polygon %p", (polygon, expected) => {
|
||||
expect(p.select("coords", { mode: "within-polygon", polygon }).allSelected())
|
||||
.toEqual(_.zip(someData, expected).filter(x => x[1]).map(x => x[0]));
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,22 @@
|
||||
import { sort, sortIndex } from "../../../src/util/typedCrossfilter/sort";
|
||||
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;
|
||||
@@ -15,42 +33,225 @@ function fillRand(arr) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
describe("sort", () => {
|
||||
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
|
||||
test(Type.name, () => {
|
||||
expect(sort(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
|
||||
Type.from([0, 1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
expect(sort(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
|
||||
Type.from([1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
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());
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const source = fillRand(new Type(1000));
|
||||
expect(sort(Type.from(source))).toMatchObject(Type.from(source).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", () => {
|
||||
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
|
||||
test(Type.name, () => {
|
||||
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
|
||||
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(
|
||||
index1.sort((a, b) => source1[a] - source1[b])
|
||||
new Uint32Array([2, 4, 6, 1, 0, 3, 5])
|
||||
);
|
||||
|
||||
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
|
||||
const source2 = new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]);
|
||||
const index2 = fillRange(new Uint32Array(source2.length));
|
||||
expect(sortIndex(index2, source2)).toMatchObject(
|
||||
index2.sort((a, b) => source1[a] - source1[b])
|
||||
new Uint32Array([2, 3, 4, 6, 1, 0, 5])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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("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, 0, 3)).toEqual(3);
|
||||
|
||||
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, 0, 3)).toEqual(3);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import crossfilter from "../../../src/util/typedCrossfilter";
|
||||
|
||||
const someData = [
|
||||
{
|
||||
date: "2011-11-14T16:17:54Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:20:19Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:28:54Z",
|
||||
quantity: 1,
|
||||
total: 300,
|
||||
tip: 200,
|
||||
type: "visa",
|
||||
productIDs: ["004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:30:43Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:48:46Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:53:41Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:54:06Z",
|
||||
quantity: 1,
|
||||
total: 100,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["001", "002", "003", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:58:03Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:07:21Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:22:59Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:25:45Z",
|
||||
quantity: 2,
|
||||
total: 200,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["002"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:29:52Z",
|
||||
quantity: 1,
|
||||
total: 200,
|
||||
tip: 100,
|
||||
type: "visa",
|
||||
productIDs: ["004"]
|
||||
}
|
||||
];
|
||||
|
||||
function groupReduce(data, valueMap, valueReduce, valueInit) {
|
||||
return _
|
||||
.reduce(
|
||||
data,
|
||||
(acc, value) => {
|
||||
const k = valueMap(value);
|
||||
let r = _.find(acc, o => o.key === k);
|
||||
if (!r) {
|
||||
r = { key: k, value: valueInit() };
|
||||
acc.push(r);
|
||||
}
|
||||
r.value = valueReduce(r.value, value);
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
)
|
||||
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
||||
}
|
||||
|
||||
function groupCount(data, map) {
|
||||
return groupReduce(data, map, (p, v) => p + 1, () => 0);
|
||||
}
|
||||
|
||||
function groupSum(data, map) {
|
||||
return groupReduce(data, map, (p, v) => (p += map(v)), () => 0);
|
||||
}
|
||||
|
||||
var payments = null;
|
||||
beforeEach(() => {
|
||||
payments = crossfilter(someData);
|
||||
});
|
||||
|
||||
describe("typedCrossfilter", () => {
|
||||
test("alloc and free", () => {
|
||||
expect(payments).toBeDefined();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
expect(quantity).toBeDefined();
|
||||
expect(quantity.id()).toBeDefined();
|
||||
|
||||
quantity.dispose();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("filterAll and filterNone", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
expect(quantity).toBeDefined();
|
||||
expect(tip).toBeDefined();
|
||||
expect(total).toBeDefined();
|
||||
expect(type).toBeDefined();
|
||||
|
||||
// initially, all should be filtered
|
||||
expect(payments.allFiltered()).toHaveLength(payments.size());
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// filterAll
|
||||
tip.filterAll(); // should change nothing
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// ditto
|
||||
total.filterAll();
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// filterNone
|
||||
type.filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
quantity.filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
// invert the first none; should have no effect because type is
|
||||
// still not filtered
|
||||
quantity.filterAll();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
// filter all of type; should select all
|
||||
type.filterAll();
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(payments.size());
|
||||
});
|
||||
|
||||
test("filterExact", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
quantity.filterExact(1);
|
||||
expect(payments.countFiltered()).toEqual(
|
||||
_.countBy(someData, "quantity")[1]
|
||||
);
|
||||
expect(payments.allFiltered()).toEqual(_.filter(someData, { quantity: 1 }));
|
||||
|
||||
tip.filterExact(0);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_.filter(someData, { tip: 0, quantity: 1 })
|
||||
);
|
||||
|
||||
type.filterExact("cash");
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_.filter(someData, { tip: 0, quantity: 1, type: "cash" })
|
||||
);
|
||||
});
|
||||
|
||||
test("filterRange", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
tip.filterRange([0, 91]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 0 && r.tip < 91)
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterRange([0, 90]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 0 && r.tip < 90)
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterRange([1, 90]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 1 && r.tip < 91)
|
||||
.value()
|
||||
);
|
||||
});
|
||||
|
||||
test("filterEnum", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
type.filterEnum(["tab", "cash"]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.type === "cash" || r.type === "tab")
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterEnum([0, 100]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.type === "cash" || r.type === "tab")
|
||||
.filter(r => r.tip === 0 || r.tip === 100)
|
||||
.value()
|
||||
);
|
||||
});
|
||||
|
||||
test("more than 32 dimensions", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
// Create a bunch of fake dimensions to ensure we can handle > 32
|
||||
let dimMap = {};
|
||||
for (let i = 0; i < 65; i++) {
|
||||
dimMap[i] = payments.dimension(r => Math.random(), Float32Array);
|
||||
expect(dimMap[i]).toBeDefined();
|
||||
expect(dimMap[i].id()).toBeDefined();
|
||||
}
|
||||
|
||||
// everything should start as selected/filtered
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
dimMap[0].filterAll();
|
||||
dimMap[64].filterAll();
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
dimMap[33].filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
|
||||
dimMap[33].filterAll();
|
||||
expect(payments.allFiltered()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("group, default mapping, default reducer, no filter", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
var quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
var tip = payments.dimension(r => r.tip, Int32Array);
|
||||
var type = payments.dimension(r => r.type, "enum");
|
||||
var total = payments.dimension(r => r.total, Int32Array);
|
||||
|
||||
_.each(
|
||||
{
|
||||
tip: tip.group(r => r),
|
||||
type: type.group(),
|
||||
total: total.group(),
|
||||
quantity: quantity.group()
|
||||
},
|
||||
(grp, k) => {
|
||||
const whatWeExpect = groupCount(someData, v => v[k]);
|
||||
expect(grp.all()).toEqual(whatWeExpect);
|
||||
expect(grp.size()).toEqual(whatWeExpect.length);
|
||||
expect(grp.dispose()).toEqual(grp);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("group, custom map, default reducer, no filters", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
// custom mapping in groups only works for scalar types. Enums do not
|
||||
// currently implement it.
|
||||
|
||||
const tip = payments.dimension(r => r.tip, Int32Array);
|
||||
const totalX10 = payments.dimension(r => r.total * 10, Int32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
const paymentsByTip_A = tip.group();
|
||||
const paymentsByTip_B = tip.group(r => 10 * r);
|
||||
const paymentsByType = type.group(); // identity only
|
||||
const paymentsByTotalX10_A = totalX10.group();
|
||||
const paymentsByTotalX10_B = totalX10.group(r => r / 10);
|
||||
|
||||
expect(paymentsByTip_A.all()).toEqual(groupCount(someData, v => v.tip));
|
||||
expect(paymentsByTip_B.all()).toEqual(
|
||||
groupCount(someData, v => 10 * v.tip)
|
||||
);
|
||||
expect(paymentsByType.all()).toEqual(groupCount(someData, v => v.type));
|
||||
expect(paymentsByTotalX10_A.all()).toEqual(
|
||||
groupCount(someData, v => 10 * v.total)
|
||||
);
|
||||
expect(paymentsByTotalX10_B.all()).toEqual(
|
||||
groupCount(someData, v => (10 * v.total) / 10)
|
||||
);
|
||||
|
||||
for (let i of [
|
||||
paymentsByTip_A,
|
||||
paymentsByTip_B,
|
||||
paymentsByType,
|
||||
paymentsByTotalX10_A,
|
||||
paymentsByTotalX10_B,
|
||||
tip,
|
||||
totalX10,
|
||||
type
|
||||
]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("group, default map, custom reducer, no filters", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
const paymentsByTotal = total.group();
|
||||
const paymentsByType = type.group();
|
||||
|
||||
// reduceCount
|
||||
expect(paymentsByTotal.reduceCount()).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(groupCount(someData, v => v.total));
|
||||
|
||||
// reduceSum
|
||||
expect(paymentsByTotal.reduceSum(v => v.total)).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(groupSum(someData, v => v.total));
|
||||
|
||||
// use custom reducers (my reducers) - count by three, init 1
|
||||
expect(
|
||||
paymentsByTotal.reduce((p, v) => (p += 3), (p, v) => (p -= 3), () => 1)
|
||||
).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(
|
||||
groupReduce(someData, v => v.total, (p, v) => p + 3, () => 1)
|
||||
);
|
||||
|
||||
for (let i of [paymentsByTotal, paymentsByType, type]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("group, default map, default reducer, filters", () => {
|
||||
// From the docs:
|
||||
// Note: a grouping intersects the crossfilter's current filters, except for the
|
||||
// associated dimension's filter. Thus, group methods consider only records that
|
||||
// satisfy every filter except this dimension's filter. So, if the crossfilter of
|
||||
// payments is filtered by type and total, then group by total only observes the
|
||||
// filter by type.
|
||||
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
const tip = payments.dimension(r => r.tip, Int32Array);
|
||||
const total = payments.dimension(r => r.total, Int32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
const paymentsByTip = tip.group();
|
||||
const paymentsByTotal = total.group();
|
||||
const paymentsByType = type.group();
|
||||
|
||||
// 1. confirm that changing the filter on a dimension does NOT change that
|
||||
// dimensions groups.
|
||||
{
|
||||
tip.filterAll(), total.filterAll(), type.filterAll();
|
||||
let before = _.cloneDeep(paymentsByTip.all());
|
||||
tip.filterExact(0);
|
||||
expect(paymentsByTip.all()).toEqual(before);
|
||||
}
|
||||
|
||||
// 2. confirm that changing a filter on a different dimension DOES change
|
||||
// all other groups.
|
||||
{
|
||||
tip.filterAll(), total.filterAll(), type.filterAll();
|
||||
const before = _.cloneDeep([paymentsByTotal.all(), paymentsByType.all()]);
|
||||
tip.filterExact(0);
|
||||
const after = [paymentsByTotal.all(), paymentsByType.all()];
|
||||
expect(after).not.toEqual(before);
|
||||
expect(after).toEqual([
|
||||
groupReduce(
|
||||
someData,
|
||||
v => v.total,
|
||||
(p, v) => (v.tip !== 0 ? p : p + 1),
|
||||
() => 0
|
||||
),
|
||||
groupReduce(
|
||||
someData,
|
||||
v => v.type,
|
||||
(p, v) => (v.tip !== 0 ? p : p + 1),
|
||||
() => 0
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
for (let i of [
|
||||
paymentsByTip,
|
||||
paymentsByTotal,
|
||||
paymentsByType,
|
||||
tip,
|
||||
total,
|
||||
type
|
||||
]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import {
|
||||
fillRange,
|
||||
sliceByIndex,
|
||||
makeSortIndex,
|
||||
lowerBound,
|
||||
lowerBoundIndirect,
|
||||
upperBoundIndirect
|
||||
makeSortIndex
|
||||
} from "../../../src/util/typedCrossfilter/util";
|
||||
import { rangeFill as fillRange } from "../../../src/util/range";
|
||||
|
||||
describe("fillRange", () => {
|
||||
test("Array", () => {
|
||||
|
||||
@@ -7,8 +7,10 @@ module.exports = {
|
||||
],
|
||||
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-class-properties", { loose: true }],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
};
|
||||
|
||||
@@ -6,10 +6,12 @@ module.exports = {
|
||||
],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
["@babel/plugin-proposal-decorators", { legacy: true }],
|
||||
["@babel/plugin-proposal-class-properties", { loose: true }],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-transform-react-constant-elements",
|
||||
"@babel/plugin-transform-runtime"
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
};
|
||||
|
||||
@@ -34,7 +34,8 @@ module.exports = {
|
||||
"object-curly-newline": ["error", { consistent: true }],
|
||||
"react/prop-types": [0],
|
||||
"space-before-function-paren": "off",
|
||||
"function-paren-newline": "off"
|
||||
"function-paren-newline": "off",
|
||||
"prefer-destructuring": ["error", { object: true, array: false }]
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ const webpack = require("webpack");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
|
||||
const src = path.resolve("src");
|
||||
const fonts = path.resolve("src/fonts");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
|
||||
const babelOptions = require("../babel/babel.dev");
|
||||
@@ -37,8 +38,9 @@ module.exports = {
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: true,
|
||||
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||
modules: {
|
||||
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -59,10 +61,16 @@ module.exports = {
|
||||
{ test: /\.json$/, include: [src, nodeModules], loader: "json-loader" },
|
||||
{
|
||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
||||
include: [src, nodeModules],
|
||||
include: nodeModules,
|
||||
loader: "file-loader",
|
||||
query: { name: "static/media/[name].[ext]" }
|
||||
},
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/,
|
||||
include: fonts,
|
||||
loader: "file-loader",
|
||||
query: { name: "static/fonts/[name].[ext]" }
|
||||
},
|
||||
{
|
||||
test: /\.(mp4|webm)(\?.*)?$/,
|
||||
include: [src, nodeModules],
|
||||
@@ -77,6 +85,12 @@ module.exports = {
|
||||
template: path.resolve("index.html"),
|
||||
favicon: path.resolve("favicon.png")
|
||||
}),
|
||||
new webpack.NoEmitOnErrorsPlugin()
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
__REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })"
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
"process.env.CXG_SERVER_PORT": JSON.stringify(process.env.CXG_SERVER_PORT),
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
@@ -6,11 +6,12 @@ const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
|
||||
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
|
||||
|
||||
const src = path.resolve("src");
|
||||
const fonts = path.resolve("src/fonts");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
|
||||
const babelOptions = require("../babel/babel.prod");
|
||||
|
||||
const publicPath = "/";
|
||||
const publicPath = "";
|
||||
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
@@ -40,9 +41,10 @@ module.exports = {
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: true,
|
||||
importLoaders: 1,
|
||||
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||
modules: {
|
||||
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||
},
|
||||
importLoaders: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -68,12 +70,15 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
||||
include: [src, nodeModules],
|
||||
include: nodeModules,
|
||||
loader: "file-loader",
|
||||
options: {
|
||||
name: "[name].[hash:8].[ext]",
|
||||
outputPath: "static/media/"
|
||||
}
|
||||
query: { name: "static/media/[name].[ext]" }
|
||||
},
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/,
|
||||
include: fonts,
|
||||
loader: "file-loader",
|
||||
query: { name: "static/fonts/[name].[ext]" }
|
||||
},
|
||||
{
|
||||
test: /\.(mp4|webm)(\?.*)?$/,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 312 B |
+1
-2
@@ -3,8 +3,7 @@
|
||||
<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">
|
||||
<title>cell×gene</title>
|
||||
<style>
|
||||
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
|
||||
font-family: 'Roboto Condensed','Helvetica Neue','Helvetica','Arial',sans-serif;
|
||||
|
||||
+32
-26
@@ -1,35 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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">
|
||||
<title>cell×gene</title>
|
||||
<style>
|
||||
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
|
||||
font-family: 'Roboto Condensed','Helvetica Neue','Helvetica','Arial',sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
window.CELLXGENE = {};
|
||||
window.CELLXGENE.API = {
|
||||
prefix: "{{ prefix | safe }}",
|
||||
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>
|
||||
</body>
|
||||
</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
+17697
-6434
File diff suppressed because it is too large
Load Diff
+90
-83
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "cellxgene",
|
||||
"version": "0.0.3",
|
||||
"version": "0.15.0",
|
||||
"license": "MIT",
|
||||
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
|
||||
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
||||
"scripts": {
|
||||
"clean": "rimraf build",
|
||||
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
|
||||
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
|
||||
"clean": "rimraf build",
|
||||
"start": "node server/development.js",
|
||||
"lint": "eslint src",
|
||||
"test": "jest"
|
||||
"fmt": "eslint --fix src",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"engines": {
|
||||
@@ -19,97 +18,98 @@
|
||||
"eslintConfig": {
|
||||
"extends": "./configuration/eslint/eslint.js"
|
||||
},
|
||||
"nyc": {
|
||||
"sourceMap": false,
|
||||
"instrument": false
|
||||
},
|
||||
"eslintIgnore": [
|
||||
"src/util/stateManager/matrix_generated.js"
|
||||
],
|
||||
"resolutions": {
|
||||
"eslint-scope": "3.7.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^3.8.0",
|
||||
"@blueprintjs/icons": "^3.3.0",
|
||||
"@blueprintjs/select": "^3.2.1",
|
||||
"canvas-fit": "^1.5.0",
|
||||
"@blueprintjs/core": "^3.24.0",
|
||||
"@blueprintjs/icons": "^3.14.0",
|
||||
"@blueprintjs/select": "^3.12.0",
|
||||
"client": "0.0.1",
|
||||
"d3": "^4.10.0",
|
||||
"d3-scale-chromatic": "^1.3.0",
|
||||
"font-color-contrast": "^1.0.3",
|
||||
"gl-mat4": "^1.1.4",
|
||||
"gl-matrix": "^2.7.1",
|
||||
"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"
|
||||
"d3-scale-chromatic": "^1.5.0",
|
||||
"flatbuffers": "^1.11.0",
|
||||
"fuzzysort": "^1.1.4",
|
||||
"gl-mat4": "^1.2.0",
|
||||
"gl-matrix": "^3.2.1",
|
||||
"gl-vec3": "^1.1.3",
|
||||
"is-number": "^7.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"memoize-one": "^5.1.1",
|
||||
"react": "^16.13.0",
|
||||
"react-dom": "^16.13.0",
|
||||
"react-flip-toolkit": "7.0.6",
|
||||
"react-helmet": "^5.2.1",
|
||||
"react-icons": "^3.9.0",
|
||||
"react-redux": "^7.2.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"regl": "^1.4.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-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-plugin-istanbul": "^5.1.0",
|
||||
"babel-preset-modern-browsers": "^12.0.0",
|
||||
"chalk": "^2.4.1",
|
||||
"connect-history-api-fallback": "^1.3.0",
|
||||
"copy-webpack-plugin": "^4.6.0",
|
||||
"css-loader": "^1.0.1",
|
||||
"eslint": "^5.8.0",
|
||||
"eslint-config-airbnb": "^17.1.0",
|
||||
"eslint-config-prettier": "^3.1.0",
|
||||
"eslint-loader": "^2.1.1",
|
||||
"@babel/core": "^7.8.7",
|
||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
"@babel/plugin-proposal-decorators": "^7.8.3",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.8.3",
|
||||
"@babel/plugin-proposal-function-bind": "^7.8.3",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.8.3",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.8.3",
|
||||
"@babel/plugin-transform-runtime": "^7.8.3",
|
||||
"@babel/preset-env": "^7.8.7",
|
||||
"@babel/preset-react": "^7.8.3",
|
||||
"@babel/register": "^7.8.6",
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^25.1.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-preset-modern-browsers": "^14.1.1",
|
||||
"chalk": "^3.0.0",
|
||||
"codecov": "^3.6.5",
|
||||
"connect-history-api-fallback": "^1.6.0",
|
||||
"copy-webpack-plugin": "^5.1.1",
|
||||
"css-loader": "^3.4.2",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-airbnb": "^18.0.1",
|
||||
"eslint-config-prettier": "^6.10.0",
|
||||
"eslint-loader": "^3.0.3",
|
||||
"eslint-plugin-filenames": "^1.3.2",
|
||||
"eslint-plugin-import": "^2.14.0",
|
||||
"eslint-plugin-jest": "^21.27.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.1.1",
|
||||
"eslint-plugin-react": "^7.11.1",
|
||||
"express": "^4.14.0",
|
||||
"file-loader": "^2.0.0",
|
||||
"eslint-plugin-import": "^2.20.1",
|
||||
"eslint-plugin-jest": "^23.8.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-react": "^7.19.0",
|
||||
"eslint-plugin-react-hooks": "^2.5.0",
|
||||
"express": "^4.17.1",
|
||||
"file-loader": "^5.1.0",
|
||||
"html-webpack-inline-source-plugin": "0.0.10",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"jest": "^23.5.0",
|
||||
"json-loader": "^0.5.4",
|
||||
"mini-css-extract-plugin": "^0.4.1",
|
||||
"nyc": "^13.0.1",
|
||||
"rimraf": "^2.5.4",
|
||||
"serve-favicon": "^2.3.0",
|
||||
"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": "^25.1.0",
|
||||
"jest-puppeteer": "^4.4.0",
|
||||
"json-loader": "^0.5.7",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"puppeteer": "^2.1.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"serve-favicon": "^2.5.0",
|
||||
"style-loader": "^1.1.3",
|
||||
"sw-precache-webpack-plugin": "^1.0.0",
|
||||
"url-loader": "^3.0.0",
|
||||
"webpack": "^4.42.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-dev-middleware": "^3.7.2"
|
||||
},
|
||||
"jest": {
|
||||
"testMatch": [
|
||||
"**/__tests__/**/?(*.)(spec|test).js?(x)"
|
||||
],
|
||||
"testURL": "http://localhost/"
|
||||
"testURL": "http://localhost/",
|
||||
"setupFiles": [
|
||||
"./__tests__/setupMissingGlobals.js"
|
||||
],
|
||||
"coverageDirectory": "./coverage/",
|
||||
"collectCoverage": true
|
||||
},
|
||||
"babel": {
|
||||
"env": {
|
||||
@@ -120,16 +120,23 @@
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
[
|
||||
"@babel/plugin-proposal-decorators",
|
||||
{
|
||||
"legacy": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
{
|
||||
"loose": true
|
||||
}
|
||||
],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-transform-react-constant-elements",
|
||||
"@babel/plugin-transform-runtime"
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ var utils = require("./utils");
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
var PORT = process.env.PORT || 3000;
|
||||
const CLIENT_PORT = process.env.CXG_CLIENT_PORT;
|
||||
|
||||
// Set up compiler
|
||||
var compiler = webpack(config);
|
||||
@@ -22,7 +22,7 @@ compiler.plugin("invalid", () => {
|
||||
});
|
||||
|
||||
compiler.plugin("done", stats => {
|
||||
utils.formatStats(stats, PORT);
|
||||
utils.formatStats(stats, CLIENT_PORT);
|
||||
});
|
||||
|
||||
// Launch server
|
||||
@@ -43,7 +43,7 @@ app.get("*", (req, res) => {
|
||||
res.sendFile(path.resolve("index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, err => {
|
||||
app.listen(CLIENT_PORT, err => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
return;
|
||||
|
||||
+245
-99
@@ -1,52 +1,142 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import * as globals from "../globals";
|
||||
import { Universe, kvCache } from "../util/stateManager";
|
||||
import { Universe, MatrixFBS } from "../util/stateManager";
|
||||
import {
|
||||
catchErrorsWrap,
|
||||
doJsonRequest,
|
||||
rangeEncodeIndices,
|
||||
doBinaryRequest,
|
||||
dispatchNetworkErrorMessageToUser
|
||||
} from "../util/actionHelpers";
|
||||
import { requestReembed, reembedResetWorldToUniverse } from "./reembed";
|
||||
|
||||
/*
|
||||
return promise to fetch the OBS annotations we need to load. Omit anything
|
||||
we don't need.
|
||||
*/
|
||||
function obsAnnotationFetchAndLoad(dispatch, schema) {
|
||||
const obsAnnotations = schema?.schema?.annotations?.obs ?? {};
|
||||
const columns = obsAnnotations.columns ?? [];
|
||||
const index = obsAnnotations.index ?? false;
|
||||
return Promise.all(
|
||||
columns
|
||||
.filter(col => col.name !== index)
|
||||
.map(col => {
|
||||
const path = `annotations/obs?annotation-name=${encodeURIComponent(
|
||||
col.name
|
||||
)}`;
|
||||
const url = `${globals.API.prefix}${globals.API.version}${path}`;
|
||||
return doBinaryRequest(url);
|
||||
})
|
||||
.map(rqst => rqst.then(buffer => Universe.matrixFBSToDataframe(buffer)))
|
||||
.map(resp =>
|
||||
resp.then(df =>
|
||||
dispatch({
|
||||
type: "universe: column load success",
|
||||
dim: "obsAnnotations",
|
||||
dataframe: df
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
return promise fetching VAR annotations we need to load. Only index is currently used.
|
||||
*/
|
||||
function varAnnotationFetchAndLoad(dispatch, schema) {
|
||||
const varAnnotations = schema?.schema?.annotations?.var ?? {};
|
||||
const index = varAnnotations.index ?? false;
|
||||
const names = index ? [index] : [];
|
||||
return Promise.all(
|
||||
names
|
||||
.map(name => {
|
||||
const path = `annotations/var?annotation-name=${encodeURIComponent(
|
||||
name
|
||||
)}`;
|
||||
const url = `${globals.API.prefix}${globals.API.version}${path}`;
|
||||
return doBinaryRequest(url);
|
||||
})
|
||||
.map(rqst => rqst.then(buffer => Universe.matrixFBSToDataframe(buffer)))
|
||||
.map(resp =>
|
||||
resp.then(df =>
|
||||
dispatch({
|
||||
type: "universe: column load success",
|
||||
dim: "varAnnotations",
|
||||
dataframe: df
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
return promise fetching layout we need
|
||||
*/
|
||||
function layoutFetchAndLoad(dispatch) {
|
||||
return Promise.all(
|
||||
["layout/obs"]
|
||||
.map(path => {
|
||||
const url = `${globals.API.prefix}${globals.API.version}${path}`;
|
||||
return doBinaryRequest(url);
|
||||
})
|
||||
.map(rqst => rqst.then(buffer => Universe.matrixFBSToDataframe(buffer)))
|
||||
.map(resp =>
|
||||
resp.then(df =>
|
||||
dispatch({
|
||||
type: "universe: column load success",
|
||||
dim: "obsLayout",
|
||||
dataframe: df
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
Bootstrap application with the initial data loading.
|
||||
* /config - application configuration
|
||||
* /schema - schema of dataframe
|
||||
* /annotations/obs - all metadata annotation
|
||||
* /annotations - all metadata annotation
|
||||
* /layout - all default layout
|
||||
*/
|
||||
const doInitialDataLoad = () =>
|
||||
catchErrorsWrap(async dispatch => {
|
||||
dispatch({ type: "initial data load start" });
|
||||
|
||||
try {
|
||||
const requests = _([
|
||||
"config",
|
||||
"schema",
|
||||
"annotations/obs",
|
||||
"annotations/var",
|
||||
"layout/obs"
|
||||
])
|
||||
/*
|
||||
Step 1 - config & schema, all JSON
|
||||
*/
|
||||
const requestJson = ["config", "schema"]
|
||||
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
|
||||
.map(url => doJsonRequest(url))
|
||||
.value();
|
||||
const results = await Promise.all(requests);
|
||||
|
||||
.map(url => doJsonRequest(url));
|
||||
const stepOneResults = await Promise.all(requestJson);
|
||||
/* set config defaults */
|
||||
const config = { ...globals.configDefaults, ...results[0].config };
|
||||
const [, schema, obsAnno, varAnno, obsLayout] = [...results];
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
config,
|
||||
schema,
|
||||
obsAnno,
|
||||
varAnno,
|
||||
obsLayout
|
||||
);
|
||||
|
||||
const config = { ...globals.configDefaults, ...stepOneResults[0].config };
|
||||
const schema = stepOneResults[1];
|
||||
const universe = Universe.createUniverseFromResponse(config, schema);
|
||||
dispatch({
|
||||
type: "universe exists, but loading is still in progress",
|
||||
universe
|
||||
});
|
||||
dispatch({
|
||||
type: "configuration load complete",
|
||||
config
|
||||
});
|
||||
|
||||
/*
|
||||
Step 2 - load the minimum stuff required to display.
|
||||
*/
|
||||
await Promise.all([
|
||||
layoutFetchAndLoad(dispatch),
|
||||
varAnnotationFetchAndLoad(dispatch, schema)
|
||||
]);
|
||||
|
||||
/*
|
||||
Step 3 - load everything else
|
||||
*/
|
||||
await obsAnnotationFetchAndLoad(dispatch, schema);
|
||||
|
||||
dispatch({
|
||||
type: "initial data load complete (universe exists)",
|
||||
universe
|
||||
@@ -60,8 +150,8 @@ const doInitialDataLoad = () =>
|
||||
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().controls;
|
||||
const setWorldToSelection = () => (dispatch, getState) => {
|
||||
const { universe, world, crossfilter } = getState();
|
||||
dispatch({
|
||||
type: "set World to current selection",
|
||||
universe,
|
||||
@@ -72,15 +162,13 @@ const regraph = () => (dispatch, getState) => {
|
||||
|
||||
// Throws
|
||||
const dispatchExpressionErrors = (dispatch, res) => {
|
||||
const msg = `Unexpected HTTP response while fetching expression data ${
|
||||
res.status
|
||||
}, ${res.statusText}`;
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -88,14 +176,49 @@ Transparently utilizes cached data if it is already present.
|
||||
*/
|
||||
async function _doRequestExpressionData(dispatch, getState, genes) {
|
||||
const state = getState();
|
||||
const { universe } = state.controls;
|
||||
const { universe } = state;
|
||||
const varIndexName = universe.schema.annotations.var.index;
|
||||
|
||||
/* helper for this function only */
|
||||
const fetchData = async geneNames => {
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}data/var`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
filter: {
|
||||
var: {
|
||||
annotation_value: [{ name: varIndexName, values: geneNames }]
|
||||
}
|
||||
}
|
||||
}),
|
||||
headers: new Headers({
|
||||
accept: "application/octet-stream",
|
||||
"Content-Type": "application/json"
|
||||
}),
|
||||
credentials: "include"
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
res.headers.get("Content-Type") !== "application/octet-stream"
|
||||
) {
|
||||
// WILL throw
|
||||
return dispatchExpressionErrors(dispatch, res);
|
||||
}
|
||||
|
||||
const data = await res.arrayBuffer();
|
||||
return Universe.convertDataFBStoObject(universe, data);
|
||||
};
|
||||
|
||||
/* preload data already in cache */
|
||||
let expressionData = _.transform(
|
||||
genes,
|
||||
(expData, g) => {
|
||||
const data = kvCache.get(universe.varDataCache, g);
|
||||
const data = universe.varData.col(g);
|
||||
if (data) {
|
||||
expData[g] = data;
|
||||
expData[g] = data.asArray();
|
||||
}
|
||||
},
|
||||
{}
|
||||
@@ -108,35 +231,10 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
|
||||
/* Fetch data for any genes not in cache */
|
||||
if (genesToFetch.length) {
|
||||
try {
|
||||
// XXX: TODO - this could be using /data/var rather than /data/obs,
|
||||
// as that would simplify the transformation in convertExpressionRESTv02ToObject
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}data/obs`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
filter: {
|
||||
var: {
|
||||
annotation_value: [{ name: "name", values: genesToFetch }]
|
||||
}
|
||||
}
|
||||
}),
|
||||
headers: new Headers({
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
|
||||
// WILL throw
|
||||
return dispatchExpressionErrors(dispatch, res);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const newExpressionData = await fetchData(genesToFetch);
|
||||
expressionData = {
|
||||
...expressionData,
|
||||
...Universe.convertExpressionRESTv02ToObject(universe, data)
|
||||
...newExpressionData
|
||||
};
|
||||
} catch (error) {
|
||||
dispatch({ type: "expression load error", error });
|
||||
@@ -153,12 +251,12 @@ function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
||||
dispatch({ type: "get single gene expression for coloring started" });
|
||||
try {
|
||||
await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState().controls;
|
||||
const { world } = getState();
|
||||
dispatch({
|
||||
type: "color by expression",
|
||||
gene,
|
||||
data: {
|
||||
[gene]: kvCache.get(world.varDataCache, gene)
|
||||
[gene]: world.varData.col(gene).asArray()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -174,14 +272,14 @@ const requestUserDefinedGene = gene => async (dispatch, getState) => {
|
||||
dispatch({ type: "request user defined gene started" });
|
||||
try {
|
||||
await await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState().controls;
|
||||
const { world } = getState();
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
type: "request user defined gene success",
|
||||
data: {
|
||||
genes: [gene],
|
||||
expression: kvCache.get(world.varDataCache, gene)
|
||||
expression: world.varData.col(gene).asArray()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -205,9 +303,7 @@ const dispatchDiffExpErrors = (dispatch, response) => {
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
const msg = `Unexpected differential expression HTTP response ${
|
||||
response.status
|
||||
}, ${response.statusText}`;
|
||||
const msg = `Unexpected differential expression HTTP response ${response.status}, ${response.statusText}`;
|
||||
dispatchNetworkErrorMessageToUser(msg);
|
||||
dispatch({
|
||||
type: "request differential expression error",
|
||||
@@ -229,13 +325,20 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
2. get expression data for each
|
||||
*/
|
||||
const state = getState();
|
||||
const { universe } = state.controls;
|
||||
const set1ByIndex = rangeEncodeIndices(
|
||||
_.map(set1, s => universe.obsNameToIndexMap[s])
|
||||
);
|
||||
const set2ByIndex = rangeEncodeIndices(
|
||||
_.map(set2, s => universe.obsNameToIndexMap[s])
|
||||
);
|
||||
const { universe } = state;
|
||||
const varIndexName = universe.schema.annotations.var.index;
|
||||
|
||||
// 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`,
|
||||
{
|
||||
@@ -247,9 +350,10 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
body: JSON.stringify({
|
||||
mode: "topN",
|
||||
count: num_genes,
|
||||
set1: { filter: { obs: { index: set1ByIndex } } },
|
||||
set2: { filter: { obs: { index: set2ByIndex } } }
|
||||
})
|
||||
set1: { filter: { obs: { index: set1 } } },
|
||||
set2: { filter: { obs: { index: set2 } } }
|
||||
}),
|
||||
credentials: "include"
|
||||
}
|
||||
);
|
||||
|
||||
@@ -259,7 +363,9 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
|
||||
const data = await res.json();
|
||||
// result is [ [varIdx, ...], ... ]
|
||||
const topNGenes = _.map(data, r => universe.varAnnotations[r[0]].name);
|
||||
const topNGenes = _.map(data, r =>
|
||||
universe.varAnnotations.at(r[0], varIndexName)
|
||||
);
|
||||
|
||||
/*
|
||||
Kick off secondary action to fetch all of the expression data for the
|
||||
@@ -280,35 +386,75 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
}
|
||||
};
|
||||
|
||||
const resetInterface = () => (dispatch, getState) => {
|
||||
const { universe } = getState().controls;
|
||||
|
||||
dispatch({
|
||||
type: "clear all user defined genes"
|
||||
});
|
||||
dispatch({
|
||||
type: "clear differential expression"
|
||||
});
|
||||
dispatch({
|
||||
type: "reset colorscale"
|
||||
});
|
||||
dispatch({
|
||||
type: "clear scatterplot"
|
||||
});
|
||||
const resetWorldToUniverse = () => (dispatch, getState) => {
|
||||
const { universe } = getState();
|
||||
reembedResetWorldToUniverse(dispatch, getState);
|
||||
dispatch({
|
||||
type: "reset World to eq Universe",
|
||||
universe
|
||||
});
|
||||
};
|
||||
|
||||
const saveObsAnnotations = () => async (dispatch, getState) => {
|
||||
const { universe, annotations } = getState();
|
||||
const { obsAnnotations, schema } = universe;
|
||||
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
|
||||
|
||||
dispatch({
|
||||
type: "increment graph render counter"
|
||||
type: "writable obs annotations - save started"
|
||||
});
|
||||
|
||||
const writableAnnotations = schema.annotations.obs.columns
|
||||
.filter(s => s.writable)
|
||||
.map(s => s.name);
|
||||
const df = obsAnnotations.subset(null, writableAnnotations);
|
||||
const matrix = MatrixFBS.encodeMatrixFBS(df);
|
||||
try {
|
||||
const queryString =
|
||||
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
||||
? `?annotation-collection-name=${encodeURIComponent(
|
||||
dataCollectionName
|
||||
)}`
|
||||
: "";
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: matrix,
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/octet-stream"
|
||||
}),
|
||||
credentials: "include"
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save complete",
|
||||
obsAnnotations
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: `HTTP error ${res.status} - ${res.statusText}`,
|
||||
res
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: error.toString(),
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
regraph,
|
||||
resetInterface,
|
||||
requestSingleGeneExpressionCountsForColoringPOST,
|
||||
doInitialDataLoad,
|
||||
requestDifferentialExpression,
|
||||
requestSingleGeneExpressionCountsForColoringPOST,
|
||||
requestUserDefinedGene,
|
||||
doInitialDataLoad
|
||||
requestReembed,
|
||||
resetWorldToUniverse,
|
||||
saveObsAnnotations,
|
||||
setWorldToSelection
|
||||
};
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { API } from "../globals";
|
||||
import { Universe } from "../util/stateManager";
|
||||
import {
|
||||
postNetworkErrorToast,
|
||||
postAsyncSuccessToast,
|
||||
postAsyncFailureToast
|
||||
} from "../components/framework/toasters";
|
||||
|
||||
function abortableFetch(request, opts, timeout = 0) {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
return {
|
||||
abort: () => controller.abort(),
|
||||
isAborted: () => signal.aborted,
|
||||
ready: () => {
|
||||
if (timeout) {
|
||||
setTimeout(() => controller.abort(), timeout);
|
||||
}
|
||||
return fetch(request, { ...opts, signal });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function doReembedFetch(dispatch, getState) {
|
||||
const state = getState();
|
||||
let cells = state.world.obsAnnotations.rowIndex.keys();
|
||||
|
||||
// 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).
|
||||
cells = Array.isArray(cells) ? cells : Array.from(cells);
|
||||
|
||||
const af = abortableFetch(
|
||||
`${API.prefix}${API.version}layout/obs`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: new Headers({
|
||||
Accept: "application/octet-stream",
|
||||
"Content-Type": "application/json"
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
method: "umap",
|
||||
filter: { obs: { index: cells } }
|
||||
}),
|
||||
credentials: "include"
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
);
|
||||
dispatch({
|
||||
type: "reembed: request start",
|
||||
abortableFetch: af
|
||||
});
|
||||
const res = await af.ready();
|
||||
|
||||
if (
|
||||
res.ok &&
|
||||
res.headers.get("Content-Type").includes("application/octet-stream")
|
||||
) {
|
||||
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}`;
|
||||
}
|
||||
postNetworkErrorToast(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/*
|
||||
functions below are dispatch-able
|
||||
*/
|
||||
export function requestReembed() {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const res = await doReembedFetch(dispatch, getState);
|
||||
const schema = JSON.parse(res.headers.get("CxG-Schema"));
|
||||
const buffer = await res.arrayBuffer();
|
||||
const df = Universe.matrixFBSToDataframe(buffer);
|
||||
dispatch({
|
||||
type: "reembed: request completed"
|
||||
});
|
||||
dispatch({
|
||||
type: "reembed: add reembedding",
|
||||
embedding: df,
|
||||
schema
|
||||
});
|
||||
postAsyncSuccessToast("Re-embedding has completed.");
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "reembed: request aborted"
|
||||
});
|
||||
if (error.name === "AbortError") {
|
||||
postAsyncFailureToast("Re-embedding calculation was aborted.");
|
||||
} else {
|
||||
postNetworkErrorToast(`Re-embedding: ${error.message}`);
|
||||
}
|
||||
console.log("Reembed exception:", error, error.name, error.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function reembedResetWorldToUniverse(dispatch, getState) {
|
||||
const { reembedController } = getState();
|
||||
if (reembedController.pendingFetch) reembedController.pendingFetch.abort();
|
||||
dispatch({
|
||||
type: "reembed: clear all reembeddings"
|
||||
});
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import Helmet from "react-helmet";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import Container from "./framework/container";
|
||||
import LeftSideBar from "./leftsidebar";
|
||||
import LeftSideBar from "./leftSidebar";
|
||||
import RightSideBar from "./rightSidebar";
|
||||
import Legend from "./continuousLegend";
|
||||
import Graph from "./graph/graph";
|
||||
import MenuBar from "./menubar";
|
||||
import Autosave from "./autosave";
|
||||
|
||||
import actions from "../actions";
|
||||
|
||||
@connect(state => ({
|
||||
@@ -71,18 +75,25 @@ class App extends React.Component {
|
||||
loading cellxgene
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{loading ? null : <LeftSideBar />}
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 15,
|
||||
width: 1440 - 410 /* but responsive */,
|
||||
marginLeft: 350 /* but responsive */
|
||||
position: "fixed",
|
||||
fontWeight: 500,
|
||||
top: window.innerHeight / 2,
|
||||
left: window.innerWidth / 2 - 50
|
||||
}}
|
||||
>
|
||||
{loading ? null : <Graph key={graphRenderCounter} />}
|
||||
<Legend />
|
||||
error loading
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{loading ? null : <LeftSideBar />}
|
||||
{loading ? null : <RightSideBar />}
|
||||
{loading ? null : <MenuBar />}
|
||||
{loading ? null : <Graph key={graphRenderCounter} />}
|
||||
{loading ? null : <Autosave />}
|
||||
<Legend />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
InputGroup,
|
||||
Dialog,
|
||||
Classes,
|
||||
Colors
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
@connect(state => ({
|
||||
universe: state.universe,
|
||||
idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null,
|
||||
annotations: state.annotations,
|
||||
obsAnnotations: state.universe.obsAnnotations,
|
||||
saveInProgress: state.autosave?.saveInProgress ?? false,
|
||||
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
|
||||
error: state.autosave?.error,
|
||||
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false
|
||||
}))
|
||||
class FilenameDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filenameText: ""
|
||||
};
|
||||
}
|
||||
|
||||
dismissFilenameDialog = () => {};
|
||||
|
||||
handleCreateFilename = () => {
|
||||
const { dispatch } = this.props;
|
||||
const { filenameText } = this.state;
|
||||
|
||||
dispatch({
|
||||
type: "set annotations collection name",
|
||||
data: filenameText
|
||||
});
|
||||
};
|
||||
|
||||
filenameError = () => {
|
||||
const legalNames = /^\w+$/;
|
||||
const { filenameText } = this.state;
|
||||
let err = false;
|
||||
|
||||
if (filenameText === "") {
|
||||
err = "empty_string";
|
||||
} else if (!legalNames.test(filenameText)) {
|
||||
/*
|
||||
IMPORTANT: this test must ultimately match the test applied by the
|
||||
backend, which is designed to ensure a safe file name can be created
|
||||
from the data collection name. If you change this, you will also need
|
||||
to change the validation code in the backend, or it will have no effect.
|
||||
*/
|
||||
err = "characters";
|
||||
}
|
||||
|
||||
return err;
|
||||
};
|
||||
|
||||
filenameErrorMessage = () => {
|
||||
const err = this.filenameError();
|
||||
let markup = null;
|
||||
|
||||
if (err === "empty_string") {
|
||||
markup = (
|
||||
<span
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
Name cannot be blank
|
||||
</span>
|
||||
);
|
||||
} else if (err === "characters") {
|
||||
markup = (
|
||||
<span
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
Only alphanumeric and underscore allowed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return markup;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { writableCategoriesEnabled, annotations, idhash } = this.props;
|
||||
const { filenameText } = this.state;
|
||||
|
||||
return writableCategoriesEnabled &&
|
||||
!annotations.dataCollectionNameIsReadOnly &&
|
||||
!annotations.dataCollectionName ? (
|
||||
<Dialog
|
||||
icon="tag"
|
||||
title="Annotations Collection"
|
||||
isOpen={!annotations.dataCollectionName}
|
||||
onClose={this.dismissFilenameDialog}
|
||||
>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
this.handleCreateFilename();
|
||||
}}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>Name your annotations collection:</p>
|
||||
<InputGroup
|
||||
autoFocus
|
||||
value={filenameText}
|
||||
intent={this.filenameError(filenameText) ? "warning" : "none"}
|
||||
onChange={e => this.setState({ filenameText: e.target.value })}
|
||||
leftIcon="tag"
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 7,
|
||||
visibility: this.filenameError(filenameText)
|
||||
? "visible"
|
||||
: "hidden",
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{this.filenameErrorMessage(filenameText)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
Your annotations are stored in this file:
|
||||
<code className="bp3-code">
|
||||
{filenameText}-{idhash}.csv
|
||||
</code>
|
||||
</p>
|
||||
<p style={{ fontStyle: "italic" }}>
|
||||
(We added a unique ID to your filename)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Tooltip content="Cancel naming collection">
|
||||
<Button onClick={this.dismissFilenameDialog}>Cancel</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabled={!filenameText || this.filenameError(filenameText)}
|
||||
onClick={this.handleCreateFilename}
|
||||
intent="primary"
|
||||
type="submit"
|
||||
>
|
||||
Create annotations collection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export default FilenameDialog;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import FilenameDialog from "./filenameDialog";
|
||||
|
||||
@connect(state => ({
|
||||
universe: state.universe,
|
||||
annotations: state.annotations,
|
||||
obsAnnotations: state.universe.obsAnnotations,
|
||||
saveInProgress: state.autosave?.saveInProgress ?? false,
|
||||
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
|
||||
error: state.autosave?.error,
|
||||
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false,
|
||||
initialDataLoadComplete: state.autosave?.initialDataLoadComplete
|
||||
}))
|
||||
class Autosave extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
timer: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { writableCategoriesEnabled } = this.props;
|
||||
|
||||
let { timer } = this.state;
|
||||
if (timer) clearInterval(timer);
|
||||
if (writableCategoriesEnabled) {
|
||||
timer = setInterval(this.tick, 2500);
|
||||
} else {
|
||||
timer = null;
|
||||
}
|
||||
this.setState({ timer });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { timer } = this.state;
|
||||
if (timer) this.clearInterval(timer);
|
||||
}
|
||||
|
||||
tick = () => {
|
||||
const { dispatch, saveInProgress } = this.props;
|
||||
if (this.needToSave() && !saveInProgress) {
|
||||
dispatch(actions.saveObsAnnotations());
|
||||
}
|
||||
};
|
||||
|
||||
needToSave = () => {
|
||||
/* return true if we need to save, false if we don't */
|
||||
const { obsAnnotations, lastSavedObsAnnotations } = this.props;
|
||||
return (
|
||||
lastSavedObsAnnotations && obsAnnotations !== lastSavedObsAnnotations
|
||||
);
|
||||
};
|
||||
|
||||
statusMessage() {
|
||||
const { error } = this.props;
|
||||
if (error) {
|
||||
return `Autosave error: ${error}`;
|
||||
}
|
||||
return this.needToSave() ? "Unsaved" : "All saved";
|
||||
}
|
||||
|
||||
render() {
|
||||
const { writableCategoriesEnabled, saveInProgress, initialDataLoadComplete } = this.props;
|
||||
return writableCategoriesEnabled ? (
|
||||
<div
|
||||
id="autosave"
|
||||
data-testclass={
|
||||
!initialDataLoadComplete
|
||||
? "autosave-init"
|
||||
: (this.needToSave() || saveInProgress)
|
||||
? "autosave-incomplete"
|
||||
: "autosave-complete"
|
||||
}
|
||||
style={{
|
||||
position: "fixed",
|
||||
display: "inherit",
|
||||
right: globals.leftSidebarWidth + 5,
|
||||
bottom: 5
|
||||
}}
|
||||
>
|
||||
{this.statusMessage()}
|
||||
<FilenameDialog />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Autosave;
|
||||
@@ -2,71 +2,80 @@
|
||||
https://bl.ocks.org/mbostock/4341954
|
||||
https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
|
||||
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
|
||||
https://bl.ocks.org/mbostock/3019563
|
||||
*/
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import memoize from "memoize-one";
|
||||
import { kvCache } from "../../util/stateManager";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import { linspace } from "../../util/range";
|
||||
import { makeContinuousDimensionName } from "../../util/nameCreators";
|
||||
|
||||
@connect(state => ({
|
||||
world: state.controls.world,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
crossfilter: state.controls.crossfilter,
|
||||
differential: state.differential,
|
||||
initializeRanges: _.get(state.controls.world, "summary.obs"),
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
obsAnnotations: _.get(state.controls.world, "obsAnnotations", null)
|
||||
}))
|
||||
class HistogramBrush extends React.Component {
|
||||
calcHistogramCache = memoize((obsAnnotations, field, ranges) => {
|
||||
const { world } = this.props;
|
||||
@connect((state, ownProps) => {
|
||||
const { isObs, isUserDefined, isDiffExp, field } = ownProps;
|
||||
const myName = makeContinuousDimensionName(
|
||||
{ isObs, isUserDefined, isDiffExp },
|
||||
field
|
||||
);
|
||||
return {
|
||||
world: state.world,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
continuousSelectionRange: state.continuousSelection[myName],
|
||||
colorAccessor: state.colors.colorAccessor
|
||||
};
|
||||
})
|
||||
class HistogramBrush extends React.PureComponent {
|
||||
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 => {
|
||||
/*
|
||||
recalculate expensive stuff, notably bins, summaries, etc.
|
||||
*/
|
||||
const histogramCache = {};
|
||||
const values = col.asArray();
|
||||
const summary = col.summarize();
|
||||
const { min: domainMin, max: domainMax } = summary;
|
||||
const numBins = 40;
|
||||
|
||||
histogramCache.x = d3
|
||||
.scaleLinear()
|
||||
.domain([domainMin, domainMax])
|
||||
.range([this.marginLeft, this.marginLeft + this.width]);
|
||||
|
||||
const [xStart, xStop] = histogramCache.x.domain();
|
||||
const histThresholds = linspace(xStart, xStop, numBins + 1);
|
||||
|
||||
histogramCache.bins = d3
|
||||
.histogram()
|
||||
.domain(histogramCache.x.domain())
|
||||
.thresholds(histThresholds)(values);
|
||||
|
||||
const yMax = histogramCache.bins
|
||||
.map(b => b.length)
|
||||
.reduce((a, b) => Math.max(a, b));
|
||||
histogramCache.y = d3
|
||||
.scaleLinear()
|
||||
.range([this.height - this.marginBottom, 0]);
|
||||
|
||||
if (obsAnnotations[0][field]) {
|
||||
// recalculate expensive stuff
|
||||
const allValuesForContinuousFieldAsArray = _.map(obsAnnotations, field);
|
||||
|
||||
histogramCache.x = d3
|
||||
.scaleLinear()
|
||||
.domain([ranges.min, ranges.max])
|
||||
.range([0, this.width]);
|
||||
|
||||
histogramCache.bins = d3
|
||||
.histogram()
|
||||
.domain(histogramCache.x.domain())
|
||||
.thresholds(40)(allValuesForContinuousFieldAsArray);
|
||||
|
||||
histogramCache.numValues = allValuesForContinuousFieldAsArray.length;
|
||||
} else if (kvCache.get(world.varDataCache, field)) {
|
||||
/* it's not in observations, so it's a gene, but let's check to make sure */
|
||||
const varValues = kvCache.get(world.varDataCache, field);
|
||||
|
||||
histogramCache.x = d3
|
||||
.scaleLinear()
|
||||
.domain(
|
||||
d3.extent(varValues)
|
||||
) /* replace this if we have ranges for genes back from server like we do for annotations on cells */
|
||||
.range([0, this.width]);
|
||||
|
||||
histogramCache.bins = d3
|
||||
.histogram()
|
||||
.domain(histogramCache.x.domain())
|
||||
.thresholds(40)(varValues);
|
||||
|
||||
histogramCache.numValues = varValues.length;
|
||||
}
|
||||
.domain([0, yMax])
|
||||
.range([this.marginTop + this.height, this.marginTop]);
|
||||
|
||||
return histogramCache;
|
||||
});
|
||||
@@ -74,34 +83,95 @@ class HistogramBrush extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.width = 340;
|
||||
this.height = 100;
|
||||
this.marginBottom = 20;
|
||||
this.marginLeft = 3; // Space for 0 tick label on X axis
|
||||
this.marginRight = 40; // space for Y axis & labels
|
||||
this.marginBottom = 25; // space for X axis & labels
|
||||
this.marginTop = 3;
|
||||
|
||||
this.width = 340 - this.marginLeft - this.marginRight;
|
||||
this.height = 135 - this.marginTop - this.marginBottom;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { field } = this.props;
|
||||
const { x, y, bins, numValues, svgRef } = this._histogram;
|
||||
const { x, y, bins, svgRef } = this._histogram;
|
||||
|
||||
this.renderAxesBrushBins(x, y, bins, numValues, svgRef, field);
|
||||
this.renderAxesBrushBins(x, y, bins, svgRef, field);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { field, obsAnnotations } = this.props;
|
||||
const { x, y, bins, numValues, svgRef } = this._histogram;
|
||||
const { field, world } = this.props;
|
||||
const { x, y, bins, svgRef } = this._histogram;
|
||||
let { brushXselection, brushX } = this.state;
|
||||
let forceBrushUpdate = false;
|
||||
|
||||
if (obsAnnotations !== prevProps.obsAnnotations) {
|
||||
this.renderAxesBrushBins(x, y, bins, numValues, svgRef, field);
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
const { continuousSelectionRange: range } = this.props;
|
||||
if (forceBrushUpdate || range !== prevProps.continuousSelectionRange) {
|
||||
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) {
|
||||
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: "continuous metadata histogram brush",
|
||||
type,
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
@@ -112,7 +182,7 @@ class HistogramBrush extends React.Component {
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
type,
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
@@ -125,41 +195,99 @@ class HistogramBrush extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
drawHistogram(svgRef) {
|
||||
const { obsAnnotations, field, ranges } = this.props;
|
||||
const histogramCache = this.calcHistogramCache(
|
||||
obsAnnotations,
|
||||
field,
|
||||
ranges
|
||||
);
|
||||
onBrushEnd(selection, x) {
|
||||
return () => {
|
||||
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
|
||||
const minAllowedBrushSize = 10;
|
||||
const smallAmountToAvoidInfiniteLoop = 0.1;
|
||||
|
||||
const { x, y, bins, numValues } = histogramCache;
|
||||
// ignore programmatically generated events
|
||||
if (!d3.event.sourceEvent) return;
|
||||
// ignore cascading events, which are programmatically generated
|
||||
if (d3.event.sourceEvent.sourceEvent) return;
|
||||
|
||||
this._histogram = { x, y, bins, numValues, svgRef };
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
handleColorAction() {
|
||||
const {
|
||||
obsAnnotations,
|
||||
dispatch,
|
||||
field,
|
||||
world,
|
||||
initializeRanges
|
||||
} = this.props;
|
||||
handleSetGeneAsScatterplotX = () => {
|
||||
const { dispatch, field } = this.props;
|
||||
dispatch({
|
||||
type: "set scatterplot x",
|
||||
data: field
|
||||
});
|
||||
};
|
||||
|
||||
if (obsAnnotations[0][field]) {
|
||||
handleSetGeneAsScatterplotY = () => {
|
||||
const { dispatch, field } = this.props;
|
||||
dispatch({
|
||||
type: "set scatterplot y",
|
||||
data: field
|
||||
});
|
||||
};
|
||||
|
||||
handleColorAction = () => {
|
||||
const { dispatch, field, world, ranges } = this.props;
|
||||
|
||||
if (world.obsAnnotations.hasCol(field)) {
|
||||
dispatch({
|
||||
type: "color by continuous metadata",
|
||||
colorAccessor: field,
|
||||
rangeMaxForColorAccessor: initializeRanges[field].range.max
|
||||
rangeForColorAccessor: ranges
|
||||
});
|
||||
} else if (kvCache.get(world.varDataCache, field)) {
|
||||
} else if (world.varData.hasCol(field)) {
|
||||
dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
removeHistogram() {
|
||||
const { dispatch, field, colorAccessor } = this.props;
|
||||
removeHistogram = () => {
|
||||
const {
|
||||
dispatch,
|
||||
field,
|
||||
colorAccessor,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor
|
||||
} = this.props;
|
||||
dispatch({
|
||||
type: "clear user defined gene",
|
||||
data: field
|
||||
@@ -169,103 +297,158 @@ class HistogramBrush extends React.Component {
|
||||
type: "reset colorscale"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleSetGeneAsScatterplotX() {
|
||||
return () => {
|
||||
const { dispatch, field } = this.props;
|
||||
if (field === scatterplotXXaccessor) {
|
||||
dispatch({
|
||||
type: "set scatterplot x",
|
||||
data: field
|
||||
data: null
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
handleSetGeneAsScatterplotY() {
|
||||
return () => {
|
||||
const { dispatch, field } = this.props;
|
||||
}
|
||||
if (field === scatterplotYYaccessor) {
|
||||
dispatch({
|
||||
type: "set scatterplot y",
|
||||
data: field
|
||||
data: null
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
drawHistogram(svgRef) {
|
||||
const { field, world } = this.props;
|
||||
const col = HistogramBrush.getColumn(world, field);
|
||||
const histogramCache = this.calcHistogramCache(col);
|
||||
const { x, y, bins } = histogramCache;
|
||||
this._histogram = { x, y, bins, svgRef };
|
||||
}
|
||||
|
||||
renderAxesBrushBins(x, y, bins, numValues, svgRef, field) {
|
||||
renderAxesBrushBins(x, y, bins, svgRef, field) {
|
||||
const svg = d3.select(svgRef);
|
||||
|
||||
/* Remove everything */
|
||||
d3.select(svgRef)
|
||||
.selectAll("*")
|
||||
.remove();
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
/* Set margins within the SVG */
|
||||
const container = svg
|
||||
.attr("width", this.width + this.marginLeft + this.marginRight)
|
||||
.attr("height", this.height + this.marginTop + this.marginBottom)
|
||||
.append("g")
|
||||
.attr("class", "histogram-container")
|
||||
.attr("transform", `translate(${this.marginLeft},${this.marginTop})`);
|
||||
|
||||
/* BINS */
|
||||
d3.select(svgRef)
|
||||
container
|
||||
.insert("g", "*")
|
||||
.attr("fill", "#bbb")
|
||||
.selectAll("rect")
|
||||
.data(bins)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", "bar")
|
||||
.attr("x", d => x(d.x0) + 1)
|
||||
.attr("y", d => y(d.length / numValues))
|
||||
.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 / numValues));
|
||||
.attr("height", d => y(0) - y(d.length));
|
||||
|
||||
/* BRUSH */
|
||||
d3.select(svgRef)
|
||||
.append("g")
|
||||
// BRUSH
|
||||
// Note the brushable area is bounded by the data on three sides, but goes down to cover the x-axis
|
||||
const brushX = d3
|
||||
.brushX()
|
||||
.extent([
|
||||
[x.range()[0], y.range()[1]],
|
||||
[x.range()[1], this.marginTop + this.height + this.marginBottom]
|
||||
])
|
||||
/*
|
||||
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 = container
|
||||
.insert("g")
|
||||
.attr("class", "brush")
|
||||
.attr("data-testid", `${svgRef.dataset.testid}-brushable-area`)
|
||||
.call(brushX);
|
||||
|
||||
/* X AXIS */
|
||||
container
|
||||
.insert("g")
|
||||
.attr("class", "axis axis--x")
|
||||
.attr("transform", `translate(0,${this.marginTop + this.height})`)
|
||||
.call(
|
||||
d3
|
||||
.brushX()
|
||||
.on("brush", this.onBrush(field, x.invert).bind(this))
|
||||
.on("end", this.onBrush(field, x.invert).bind(this))
|
||||
.axisBottom(x)
|
||||
.ticks(5)
|
||||
.tickFormat(d3.format(".0s"))
|
||||
);
|
||||
|
||||
/* AXIS */
|
||||
d3.select(svgRef)
|
||||
.append("g")
|
||||
.attr("class", "axis axis--x")
|
||||
.attr("transform", `translate(0,${this.height - this.marginBottom})`)
|
||||
.call(d3.axisBottom(x).ticks(5));
|
||||
/* Y AXIS */
|
||||
container
|
||||
.insert("g")
|
||||
.attr("class", "axis axis--y")
|
||||
.attr("transform", `translate(${this.marginLeft + this.width},0)`)
|
||||
.call(
|
||||
d3
|
||||
.axisRight(y)
|
||||
.ticks(3)
|
||||
.tickFormat(d3.format(".0s"))
|
||||
);
|
||||
|
||||
d3.select(svgRef)
|
||||
.selectAll(".axis--x text")
|
||||
.style("fill", "rgb(80,80,80)");
|
||||
/* 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)");
|
||||
|
||||
d3.select(svgRef)
|
||||
.selectAll(".axis--x path")
|
||||
.style("stroke", "rgb(230,230,230)");
|
||||
|
||||
d3.select(svgRef)
|
||||
.selectAll(".axis--x line")
|
||||
.style("stroke", "rgb(230,230,230)");
|
||||
const newState = { brushX, brushXselection };
|
||||
this.setState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
field,
|
||||
world,
|
||||
colorAccessor,
|
||||
isUserDefined,
|
||||
isDiffExp,
|
||||
logFoldChange,
|
||||
pval,
|
||||
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_${field}`}
|
||||
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" }}>
|
||||
{isDiffExp ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
paddingBottom: "8px"
|
||||
}}
|
||||
>
|
||||
{isDiffExp || isUserDefined ? (
|
||||
<span>
|
||||
<span
|
||||
style={{ marginRight: 7 }}
|
||||
@@ -273,14 +456,16 @@ class HistogramBrush extends React.Component {
|
||||
/>
|
||||
<ButtonGroup style={{ marginRight: 7 }}>
|
||||
<Button
|
||||
onClick={this.handleSetGeneAsScatterplotX(field).bind(this)}
|
||||
data-testid={`plot-x-${field}`}
|
||||
onClick={this.handleSetGeneAsScatterplotX}
|
||||
active={scatterplotXXaccessor === field}
|
||||
intent={scatterplotXXaccessor === field ? "primary" : "none"}
|
||||
>
|
||||
plot x
|
||||
</Button>
|
||||
<Button
|
||||
onClick={this.handleSetGeneAsScatterplotY(field).bind(this)}
|
||||
data-testid={`plot-y-${field}`}
|
||||
onClick={this.handleSetGeneAsScatterplotY}
|
||||
active={scatterplotYYaccessor === field}
|
||||
intent={scatterplotYYaccessor === field ? "primary" : "none"}
|
||||
>
|
||||
@@ -292,7 +477,7 @@ class HistogramBrush extends React.Component {
|
||||
{isUserDefined ? (
|
||||
<Button
|
||||
minimal
|
||||
onClick={this.removeHistogram.bind(this)}
|
||||
onClick={this.removeHistogram}
|
||||
style={{
|
||||
color: globals.blue,
|
||||
cursor: "pointer",
|
||||
@@ -302,11 +487,17 @@ class HistogramBrush extends React.Component {
|
||||
remove
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip content="Use as color scale" position="bottom">
|
||||
<Tooltip
|
||||
content="Use as color scale"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
onClick={this.handleColorAction.bind(this)}
|
||||
onClick={this.handleColorAction}
|
||||
active={colorAccessor === field}
|
||||
intent={colorAccessor === field ? "primary" : "none"}
|
||||
data-testclass="colorby"
|
||||
data-testid={`colorby-${field}`}
|
||||
icon="tint"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -314,7 +505,9 @@ class HistogramBrush extends React.Component {
|
||||
<svg
|
||||
width={this.width}
|
||||
height={this.height}
|
||||
id={`histogram_${field}_svg`}
|
||||
id={`histogram_${fieldForId}_svg`}
|
||||
data-testclass="histogram-plot"
|
||||
data-testid={`histogram-${field}-plot`}
|
||||
ref={svgRef => {
|
||||
this.drawHistogram(svgRef);
|
||||
}}
|
||||
@@ -322,10 +515,21 @@ class HistogramBrush extends React.Component {
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center"
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
>
|
||||
<span style={{ fontStyle: "italic" }}>{field}</span>
|
||||
<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 ? (
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe
|
||||
}))
|
||||
class AnnoDialog extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isActive,
|
||||
text,
|
||||
title,
|
||||
instruction,
|
||||
cancelTooltipContent,
|
||||
errorMessage,
|
||||
validationError,
|
||||
annoSelect,
|
||||
annoInput,
|
||||
handleCancel,
|
||||
handleSubmit,
|
||||
primaryButtonText,
|
||||
secondaryButtonText,
|
||||
handleSecondaryButtonSubmit,
|
||||
primaryButtonProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Dialog icon="tag" title={title} isOpen={isActive} onClose={handleCancel}>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>{instruction}</p>
|
||||
{annoInput || null}
|
||||
<p
|
||||
style={{
|
||||
marginTop: 7,
|
||||
visibility: validationError ? "visible" : "hidden",
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
{annoSelect || null}
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Tooltip content={cancelTooltipContent}>
|
||||
<Button onClick={handleCancel}>Cancel</Button>
|
||||
</Tooltip>
|
||||
{handleSecondaryButtonSubmit && secondaryButtonText ? (
|
||||
<Button
|
||||
onClick={handleSecondaryButtonSubmit}
|
||||
disabled={!text || validationError}
|
||||
intent="none"
|
||||
type="button"
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
{...primaryButtonProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
onClick={handleSubmit}
|
||||
disabled={!text || validationError}
|
||||
intent="primary"
|
||||
type="submit"
|
||||
>
|
||||
{primaryButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AnnoDialog;
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import AnnoDialog from "./annoDialog";
|
||||
import LabelInput from "./labelInput";
|
||||
import { labelPrompt, isLabelErroneous } from "./labelUtil";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe,
|
||||
ontology: state.ontology,
|
||||
crossfilter: state.crossfilter
|
||||
}))
|
||||
class Category extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
newLabelText: ""
|
||||
};
|
||||
}
|
||||
|
||||
disableAddNewLabelMode = e => {
|
||||
const { dispatch } = this.props;
|
||||
this.setState({
|
||||
newLabelText: ""
|
||||
});
|
||||
dispatch({
|
||||
type: "annotation: disable add new label mode"
|
||||
});
|
||||
if (e) e.preventDefault();
|
||||
};
|
||||
|
||||
handleAddNewLabelToCategory = e => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const { newLabelText } = this.state;
|
||||
|
||||
this.disableAddNewLabelMode();
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
metadataField,
|
||||
newLabelText,
|
||||
assignSelectedCells: false
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
addLabelAndAssignCells = e => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const { newLabelText } = this.state;
|
||||
|
||||
this.disableAddNewLabelMode();
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
metadataField,
|
||||
newLabelText,
|
||||
assignSelectedCells: true
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
labelNameError = name => {
|
||||
const { metadataField, ontology, universe } = this.props;
|
||||
return isLabelErroneous(name, metadataField, ontology, universe.schema);
|
||||
};
|
||||
|
||||
instruction = label => {
|
||||
return labelPrompt(this.labelNameError(label), "New, unique label", ":");
|
||||
};
|
||||
|
||||
handleChangeOrSelect = label => {
|
||||
this.setState({ newLabelText: label });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { newLabelText } = this.state;
|
||||
const { metadataField, annotations, ontology, crossfilter } = this.props;
|
||||
const ontologyEnabled = ontology?.enabled ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnoDialog
|
||||
isActive={
|
||||
annotations.isAddingNewLabel &&
|
||||
annotations.categoryAddingNewLabel === metadataField
|
||||
}
|
||||
inputProps={{ "data-testid": `${metadataField}:create-label-dialog` }}
|
||||
primaryButtonProps={{
|
||||
"data-testid": `${metadataField}:submit-label`
|
||||
}}
|
||||
title="Add new label to category"
|
||||
instruction={this.instruction(newLabelText)}
|
||||
cancelTooltipContent="Close this dialog without adding a label."
|
||||
primaryButtonText="Add label"
|
||||
secondaryButtonText={`Add label and assign ${crossfilter.countSelected()} currently selected cells`}
|
||||
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
|
||||
text={newLabelText}
|
||||
validationError={this.labelNameError(newLabelText)}
|
||||
handleSubmit={this.handleAddNewLabelToCategory}
|
||||
handleCancel={this.disableAddNewLabelMode}
|
||||
annoInput={
|
||||
<LabelInput
|
||||
labelSuggestions={ontologyEnabled ? ontology.terms : null}
|
||||
onChange={this.handleChangeOrSelect}
|
||||
onSelect={this.handleChangeOrSelect}
|
||||
inputProps={{
|
||||
"data-testid": `${metadataField}:new-label-name`,
|
||||
leftIcon: "tag",
|
||||
intent: "none",
|
||||
autoFocus: true
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Category;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import AnnoDialog from "./annoDialog";
|
||||
import LabelInput from "./labelInput";
|
||||
import { labelPrompt } from "./labelUtil";
|
||||
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe,
|
||||
ontology: state.ontology
|
||||
}))
|
||||
class AnnoDialogEditCategoryName extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
newCategoryText: props.metadataField
|
||||
};
|
||||
}
|
||||
|
||||
handleChangeOrSelect = name => {
|
||||
this.setState({
|
||||
newCategoryText: name
|
||||
});
|
||||
};
|
||||
|
||||
disableEditCategoryMode = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: disable category edit mode"
|
||||
});
|
||||
this.setState({ newCategoryText: metadataField });
|
||||
};
|
||||
|
||||
handleEditCategory = e => {
|
||||
const { dispatch, metadataField, categoricalSelection } = this.props;
|
||||
const { newCategoryText } = this.state;
|
||||
|
||||
const allCategoryNames = _.keys(categoricalSelection);
|
||||
|
||||
if (
|
||||
(allCategoryNames.indexOf(newCategoryText) > -1 &&
|
||||
newCategoryText !== metadataField) ||
|
||||
newCategoryText === ""
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disableEditCategoryMode();
|
||||
dispatch({
|
||||
type: "annotation: category edited",
|
||||
metadataField,
|
||||
newCategoryText,
|
||||
data: newCategoryText
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
editedCategoryNameError = name => {
|
||||
const { metadataField, categoricalSelection } = this.props;
|
||||
|
||||
/* check for syntax errors in category name */
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/* check for duplicative categories */
|
||||
const allCategoryNames = _.keys(categoricalSelection);
|
||||
const categoryNameAlreadyExists = allCategoryNames.indexOf(name) > -1;
|
||||
const sameName = name === metadataField;
|
||||
if (categoryNameAlreadyExists && !sameName) {
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
};
|
||||
|
||||
instruction = name => {
|
||||
return labelPrompt(
|
||||
this.editedCategoryNameError(name),
|
||||
"New, unique category name",
|
||||
":"
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { newCategoryText } = this.state;
|
||||
const { metadataField, annotations, ontology } = this.props;
|
||||
const ontologyEnabled = ontology?.enabled ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnoDialog
|
||||
isActive={
|
||||
annotations.isEditingCategoryName &&
|
||||
annotations.categoryBeingEdited === metadataField
|
||||
}
|
||||
inputProps={{
|
||||
"data-testid": `${metadataField}:edit-category-name-dialog`
|
||||
}}
|
||||
primaryButtonProps={{
|
||||
"data-testid": `${metadataField}:submit-category-edit`
|
||||
}}
|
||||
title="Edit category name"
|
||||
instruction={this.instruction(newCategoryText)}
|
||||
cancelTooltipContent="Close this dialog without editing this category."
|
||||
primaryButtonText="Edit category name"
|
||||
text={newCategoryText}
|
||||
validationError={this.editedCategoryNameError(newCategoryText)}
|
||||
handleSubmit={this.handleEditCategory}
|
||||
handleCancel={this.disableEditCategoryMode}
|
||||
annoInput={
|
||||
<LabelInput
|
||||
label={newCategoryText}
|
||||
labelSuggestions={ontologyEnabled ? ontology.terms : null}
|
||||
onChange={this.handleChangeOrSelect}
|
||||
onSelect={this.handleChangeOrSelect}
|
||||
inputProps={{
|
||||
"data-testid": `${metadataField}:edit-category-name-text`,
|
||||
leftIcon: "tag",
|
||||
intent: "none",
|
||||
autoFocus: true
|
||||
}}
|
||||
newLabelMessage="New category"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AnnoDialogEditCategoryName;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Position,
|
||||
PopoverInteractionKind
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
@connect(state => ({
|
||||
annotations: state.annotations
|
||||
}))
|
||||
class AnnoMenuCategory extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
activateAddNewLabelMode = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: activate add new label mode",
|
||||
data: metadataField
|
||||
});
|
||||
};
|
||||
|
||||
activateEditCategoryMode = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
|
||||
dispatch({
|
||||
type: "annotation: activate category edit mode",
|
||||
data: metadataField
|
||||
});
|
||||
};
|
||||
|
||||
handleDeleteCategory = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: delete category",
|
||||
metadataField
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
metadataField,
|
||||
annotations,
|
||||
isUserAnno,
|
||||
createText,
|
||||
editText,
|
||||
deleteText
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isUserAnno ? (
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER}
|
||||
boundary="window"
|
||||
position={Position.RIGHT_TOP}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="tag"
|
||||
data-testclass="handleAddNewLabelToCategory"
|
||||
data-testid={`${metadataField}:add-new-label-to-category`}
|
||||
onClick={this.activateAddNewLabelMode}
|
||||
text={createText}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="edit"
|
||||
disabled={annotations.isEditingCategoryName}
|
||||
data-testclass="activateEditCategoryMode"
|
||||
data-testid={`${metadataField}:edit-category-mode`}
|
||||
onClick={this.activateEditCategoryMode}
|
||||
text={editText}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="delete"
|
||||
intent="danger"
|
||||
data-testclass="handleDeleteCategory"
|
||||
data-testid={`${metadataField}:delete-category`}
|
||||
onClick={this.handleDeleteCategory}
|
||||
text={deleteText}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
style={{ marginLeft: 0 }}
|
||||
data-testclass="seeActions"
|
||||
data-testid={`${metadataField}:see-actions`}
|
||||
icon="more"
|
||||
minimal
|
||||
/>
|
||||
</Popover>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AnnoMenuCategory;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Button, MenuItem } from "@blueprintjs/core";
|
||||
import { Select } from "@blueprintjs/select";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe
|
||||
}))
|
||||
class DuplicateCategorySelect extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
allCategoryNames,
|
||||
categoryToDuplicate,
|
||||
handleModalDuplicateCategorySelection
|
||||
} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
Optionally duplicate all labels & cell assignments from existing
|
||||
category into new category:
|
||||
</p>
|
||||
<Select
|
||||
items={
|
||||
allCategoryNames ||
|
||||
[] /* this is a placeholder, could be a subcomponent to avoid this */
|
||||
}
|
||||
filterable={false}
|
||||
itemRenderer={(d, { handleClick }) => {
|
||||
return <MenuItem onClick={handleClick} key={d} text={d} />;
|
||||
}}
|
||||
noResults={<MenuItem disabled text="No results." />}
|
||||
onItemSelect={d => {
|
||||
handleModalDuplicateCategorySelection(d);
|
||||
}}
|
||||
>
|
||||
{/* children become the popover target; render value here */}
|
||||
<Button
|
||||
text={categoryToDuplicate || "None (all cells 'unassigned')"}
|
||||
rightIcon="double-caret-vertical"
|
||||
/>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DuplicateCategorySelect;
|
||||
@@ -0,0 +1,4 @@
|
||||
:local(.hover),
|
||||
:local(.value):hover {
|
||||
background: rgba(167, 182, 194, 0.3);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,138 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { Button } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import Category from "./category";
|
||||
|
||||
/* Cap the max number of displayed categories */
|
||||
const truncateCategories = options => {
|
||||
const numOptions = _.size(options);
|
||||
if (numOptions <= globals.maxCategoricalOptionsToDisplay) {
|
||||
return options;
|
||||
}
|
||||
return _(options)
|
||||
.map((v, k) => ({ name: k, val: v }))
|
||||
.sortBy("val")
|
||||
.slice(numOptions - globals.maxCategoricalOptionsToDisplay)
|
||||
.transform((r, v) => {
|
||||
r[v.name] = v.val;
|
||||
}, {})
|
||||
.value();
|
||||
};
|
||||
import { AnnotationsHelpers, ControlsHelpers } from "../../util/stateManager";
|
||||
import AnnoDialog from "./annoDialog";
|
||||
import AnnoSelect from "./annoSelect";
|
||||
import LabelInput from "./labelInput";
|
||||
import { labelPrompt } from "./labelUtil";
|
||||
|
||||
@connect(state => ({
|
||||
ranges: _.get(state.controls.world, "summary.obs", null),
|
||||
categorySelectionLimit: _.get(
|
||||
state.config,
|
||||
"parameters.max-category-items",
|
||||
globals.configDefaults.parameters["max-category-items"]
|
||||
)
|
||||
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false,
|
||||
schema: state.world?.schema,
|
||||
config: state.config,
|
||||
ontology: state.ontology
|
||||
}))
|
||||
class Categories extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
createAnnoModeActive: false,
|
||||
newCategoryText: "",
|
||||
categoryToDuplicate: null,
|
||||
expandedCats: new Set()
|
||||
};
|
||||
}
|
||||
|
||||
handleCreateUserAnno = e => {
|
||||
const { dispatch } = this.props;
|
||||
const { newCategoryText, categoryToDuplicate } = this.state;
|
||||
dispatch({
|
||||
type: "annotation: create category",
|
||||
data: newCategoryText,
|
||||
categoryToDuplicate
|
||||
});
|
||||
this.setState({
|
||||
createAnnoModeActive: false,
|
||||
categoryToDuplicate: null,
|
||||
newCategoryText: ""
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleEnableAnnoMode = () => {
|
||||
this.setState({ createAnnoModeActive: true });
|
||||
};
|
||||
|
||||
handleDisableAnnoMode = () => {
|
||||
this.setState({
|
||||
createAnnoModeActive: false,
|
||||
categoryToDuplicate: null,
|
||||
newCategoryText: ""
|
||||
});
|
||||
};
|
||||
|
||||
handleModalDuplicateCategorySelection = d => {
|
||||
this.setState({ categoryToDuplicate: d });
|
||||
};
|
||||
|
||||
categoryNameError = name => {
|
||||
/*
|
||||
return false if this is a LEGAL/acceptable category name or NULL/empty string,
|
||||
or return an error type.
|
||||
*/
|
||||
|
||||
/* allow empty string */
|
||||
if (name === "") return false;
|
||||
|
||||
/*
|
||||
test for uniqueness against *all* annotation names, not just the subset
|
||||
we render as categorical.
|
||||
*/
|
||||
const { schema } = this.props;
|
||||
const allCategoryNames = schema.annotations.obs.columns.map(c => c.name);
|
||||
|
||||
/* check category name syntax */
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/* disallow duplicates */
|
||||
if (allCategoryNames.indexOf(name) !== -1) {
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
};
|
||||
|
||||
handleChange = name => {
|
||||
this.setState({ newCategoryText: name });
|
||||
};
|
||||
|
||||
handleSelect = name => {
|
||||
this.setState({ newCategoryText: name });
|
||||
};
|
||||
|
||||
instruction = name => {
|
||||
return labelPrompt(
|
||||
this.categoryNameError(name),
|
||||
"New, unique category name",
|
||||
":"
|
||||
);
|
||||
};
|
||||
|
||||
onExpansionChange = catName => {
|
||||
const {expandedCats} = this.state;
|
||||
if (expandedCats.has(catName)) {
|
||||
const _expandedCats = new Set(expandedCats);
|
||||
_expandedCats.delete(catName)
|
||||
this.setState({expandedCats: _expandedCats})
|
||||
} else {
|
||||
const _expandedCats = new Set(expandedCats);
|
||||
_expandedCats.add(catName)
|
||||
this.setState({expandedCats: _expandedCats})
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { ranges, categorySelectionLimit } = this.props;
|
||||
if (!ranges) return null;
|
||||
const {
|
||||
createAnnoModeActive,
|
||||
categoryToDuplicate,
|
||||
newCategoryText,
|
||||
expandedCats
|
||||
} = this.state;
|
||||
const { writableCategoriesEnabled, schema, config, ontology } = this.props;
|
||||
const ontologyEnabled = ontology?.enabled ?? false;
|
||||
/* all names, sorted in display order. Will be rendered in this order */
|
||||
const allCategoryNames = ControlsHelpers.selectableCategoryNames(
|
||||
schema,
|
||||
ControlsHelpers.maxCategoryItems(config)
|
||||
).sort();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -40,34 +140,80 @@ class Categories extends React.Component {
|
||||
padding: globals.leftSidebarSectionPadding
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={Object.assign({}, globals.leftSidebarSectionHeading, {
|
||||
marginTop: 4
|
||||
})}
|
||||
>
|
||||
Categorical Metadata
|
||||
</p>
|
||||
{_.map(ranges, (value, key) => {
|
||||
const isColorField = key.includes("color") || key.includes("Color");
|
||||
const isSelectableCategory =
|
||||
value.options &&
|
||||
!isColorField &&
|
||||
key !== "name" &&
|
||||
value.numOptions < categorySelectionLimit;
|
||||
|
||||
if (isSelectableCategory) {
|
||||
const categoryOptions = truncateCategories(value.options);
|
||||
return (
|
||||
<Category
|
||||
key={key}
|
||||
metadataField={key}
|
||||
values={categoryOptions}
|
||||
isTruncated={categoryOptions !== value.options}
|
||||
/>
|
||||
);
|
||||
<AnnoDialog
|
||||
isActive={createAnnoModeActive}
|
||||
title="Create new category"
|
||||
instruction={this.instruction(newCategoryText)}
|
||||
cancelTooltipContent="Close this dialog without creating a category."
|
||||
primaryButtonText="Create new category"
|
||||
primaryButtonProps={{ "data-testid": "submit-category" }}
|
||||
text={newCategoryText}
|
||||
validationError={this.categoryNameError(newCategoryText)}
|
||||
handleSubmit={this.handleCreateUserAnno}
|
||||
handleCancel={this.handleDisableAnnoMode}
|
||||
annoInput={
|
||||
<LabelInput
|
||||
labelSuggestions={ontologyEnabled ? ontology.terms : null}
|
||||
onChange={this.handleChange}
|
||||
onSelect={this.handleSelect}
|
||||
inputProps={{
|
||||
"data-testid": "new-category-name",
|
||||
leftIcon: "tag",
|
||||
intent: "none",
|
||||
autoFocus: true
|
||||
}}
|
||||
newLabelMessage="New category"
|
||||
/>
|
||||
}
|
||||
return undefined;
|
||||
})}
|
||||
annoSelect={
|
||||
<AnnoSelect
|
||||
handleModalDuplicateCategorySelection={
|
||||
this.handleModalDuplicateCategorySelection
|
||||
}
|
||||
categoryToDuplicate={categoryToDuplicate}
|
||||
allCategoryNames={allCategoryNames}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* READ ONLY CATEGORICAL FIELDS */}
|
||||
{/* this is duplicative but flat, could be abstracted */}
|
||||
{allCategoryNames.map(catName =>
|
||||
!schema.annotations.obsByName[catName].writable ? (
|
||||
<Category
|
||||
key={catName}
|
||||
metadataField={catName}
|
||||
onExpansionChange={this.onExpansionChange}
|
||||
isExpanded={expandedCats.has(catName)}
|
||||
createAnnoModeActive={createAnnoModeActive}
|
||||
isUserAnno={false}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{/* WRITEABLE FIELDS */}
|
||||
{allCategoryNames.map(catName =>
|
||||
schema.annotations.obsByName[catName].writable ? (
|
||||
<Category
|
||||
key={catName}
|
||||
metadataField={catName}
|
||||
onExpansionChange={this.onExpansionChange}
|
||||
isExpanded={expandedCats.has(catName)}
|
||||
createAnnoModeActive={createAnnoModeActive}
|
||||
isUserAnno
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{writableCategoriesEnabled ? (
|
||||
<div>
|
||||
<Button
|
||||
data-testid="open-annotation-dialog"
|
||||
onClick={this.handleEnableAnnoMode}
|
||||
intent="primary"
|
||||
>
|
||||
Create new category
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,67 +2,70 @@ import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||
import memoize from "memoize-one";
|
||||
import { Button, Tooltip, Position } from "@blueprintjs/core";
|
||||
import { AnchorButton, Button, Tooltip, Icon } from "@blueprintjs/core";
|
||||
import CategoryFlipperLayout from "./categoryFlipperLayout";
|
||||
import AnnoMenu from "./annoMenuCategory";
|
||||
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
|
||||
import AnnoDialogAddLabel from "./annoDialogAddLabel";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import Value from "./value";
|
||||
import alphabeticallySortedValues from "./util";
|
||||
|
||||
const countCategories = (values, optsAsBools) =>
|
||||
_.reduce(
|
||||
values,
|
||||
(r, v, k) => {
|
||||
r.total += 1;
|
||||
if (optsAsBools[k]) {
|
||||
r.on += 1;
|
||||
}
|
||||
return r;
|
||||
},
|
||||
{ total: 0, on: 0 }
|
||||
);
|
||||
import maybeTruncateString from "../../util/maybeTruncateString";
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe
|
||||
}))
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isChecked: true,
|
||||
isExpanded: false
|
||||
isChecked: true
|
||||
};
|
||||
this.countCategories = memoize((values, optsAsBools) =>
|
||||
countCategories(values, optsAsBools)
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { categoricalAsBooleansMap, metadataField, values } = this.props;
|
||||
const categoryCount = this.countCategories(
|
||||
values,
|
||||
categoricalAsBooleansMap[metadataField]
|
||||
);
|
||||
if (categoryCount.on === categoryCount.total) {
|
||||
/* everything is on, so not indeterminate */
|
||||
this.checkbox.indeterminate = false;
|
||||
} else if (categoryCount.on === 0) {
|
||||
/* nothing is on, so no */
|
||||
this.checkbox.indeterminate = false;
|
||||
} else if (categoryCount.on < categoryCount.total) {
|
||||
/* to be explicit... */
|
||||
this.checkbox.indeterminate = true;
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelection, metadataField } = this.props;
|
||||
const cat = categoricalSelection?.[metadataField];
|
||||
if (
|
||||
categoricalSelection !== prevProps.categoricalSelection &&
|
||||
!!cat &&
|
||||
!!this.checkbox
|
||||
) {
|
||||
const categoryCount = {
|
||||
// total number of categories in this dimension
|
||||
totalCatCount: cat.numCategoryValues,
|
||||
// number of selected options in this category
|
||||
selectedCatCount: _.reduce(
|
||||
cat.categoryValueSelected,
|
||||
(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() {
|
||||
handleColorChange = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "color by categorical metadata",
|
||||
colorAccessor: metadataField
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleAll() {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
@@ -74,11 +77,10 @@ class Category extends React.Component {
|
||||
}
|
||||
|
||||
toggleNone() {
|
||||
const { dispatch, metadataField, value } = this.props;
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter none of these",
|
||||
metadataField,
|
||||
value
|
||||
metadataField
|
||||
});
|
||||
this.setState({ isChecked: false });
|
||||
}
|
||||
@@ -88,32 +90,19 @@ class Category extends React.Component {
|
||||
// || this.checkbox.indeterminate === false
|
||||
if (isChecked) {
|
||||
this.toggleNone();
|
||||
} else if (!isChecked) {
|
||||
} else {
|
||||
this.toggleAll();
|
||||
}
|
||||
}
|
||||
|
||||
renderCategoryItems() {
|
||||
const { values, metadataField } = this.props;
|
||||
return _.map(alphabeticallySortedValues(values), (v, i) => (
|
||||
<Value
|
||||
key={v}
|
||||
metadataField={metadataField}
|
||||
count={values[v]}
|
||||
value={v}
|
||||
i={i}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isExpanded, isChecked } = this.state;
|
||||
const { metadataField, colorAccessor, isTruncated } = this.props;
|
||||
renderIsStillLoading() {
|
||||
/*
|
||||
We are still loading this category, so render a "busy" signal.
|
||||
*/
|
||||
const { metadataField } = this.props;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// display: "flex",
|
||||
// alignItems: "baseline",
|
||||
maxWidth: globals.maxControlsWidth
|
||||
}}
|
||||
>
|
||||
@@ -128,58 +117,148 @@ class Category extends React.Component {
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "baseline"
|
||||
alignItems: "flex-start"
|
||||
}}
|
||||
>
|
||||
<label className="bp3-control bp3-checkbox">
|
||||
<input
|
||||
onChange={this.handleToggleAllClick.bind(this)}
|
||||
ref={el => {
|
||||
this.checkbox = el;
|
||||
return el;
|
||||
}}
|
||||
checked={isChecked}
|
||||
type="checkbox"
|
||||
/>
|
||||
<input disabled checked={true} type="checkbox" />
|
||||
<span className="bp3-control-indicator" />
|
||||
{""}
|
||||
</label>
|
||||
|
||||
<span
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-block"
|
||||
}}
|
||||
onClick={() => {
|
||||
this.setState({ isExpanded: !isExpanded });
|
||||
}}
|
||||
>
|
||||
{metadataField}
|
||||
{isExpanded ? (
|
||||
<FaChevronDown style={{ fontSize: 10, marginLeft: 5 }} />
|
||||
) : (
|
||||
<FaChevronRight style={{ fontSize: 10, marginLeft: 5 }} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip content="Use as color scale" position="bottom">
|
||||
<Button
|
||||
onClick={this.handleColorChange.bind(this)}
|
||||
<div>
|
||||
<Button minimal loading intent="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isChecked } = this.state;
|
||||
const {
|
||||
metadataField,
|
||||
categoricalSelection,
|
||||
colorAccessor,
|
||||
isUserAnno,
|
||||
annotations,
|
||||
isExpanded,
|
||||
onExpansionChange
|
||||
} = this.props;
|
||||
|
||||
const isStillLoading = !(categoricalSelection?.[metadataField] ?? false);
|
||||
if (isStillLoading) {
|
||||
return this.renderIsStillLoading();
|
||||
}
|
||||
|
||||
const isTruncated = _.get(
|
||||
categoricalSelection,
|
||||
[metadataField, "isTruncated"],
|
||||
false
|
||||
);
|
||||
|
||||
return (
|
||||
<CategoryFlipperLayout
|
||||
metadataField={metadataField}
|
||||
isExpanded={isExpanded}
|
||||
isUserAnno={isUserAnno}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-start"
|
||||
}}
|
||||
>
|
||||
<label className="bp3-control bp3-checkbox">
|
||||
<input
|
||||
data-testclass="category-select"
|
||||
data-testid={`${metadataField}:category-select`}
|
||||
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={`${metadataField}:category-expand`}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-block"
|
||||
}}
|
||||
onClick={() => {
|
||||
const editingCategory =
|
||||
annotations.isEditingCategoryName &&
|
||||
annotations.categoryBeingEdited === metadataField;
|
||||
if (!editingCategory) {
|
||||
onExpansionChange(metadataField);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isUserAnno ? (
|
||||
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
|
||||
) : null}
|
||||
{maybeTruncateString(
|
||||
metadataField,
|
||||
globals.categoryDisplayStringMaxLength
|
||||
)}
|
||||
{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>
|
||||
{<AnnoDialogEditCategoryName metadataField={metadataField} />}
|
||||
{<AnnoDialogAddLabel metadataField={metadataField} />}
|
||||
<div>
|
||||
<AnnoMenu
|
||||
metadataField={metadataField}
|
||||
isUserAnno={isUserAnno}
|
||||
createText="Add a new label to this category"
|
||||
editText="Edit this category's name"
|
||||
deleteText="Delete this category, all associated labels, and remove all cell assignments"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={
|
||||
isTruncated
|
||||
? `Coloring by ${metadataField} is disabled, as it exceeds the limit of ${globals.maxCategoricalOptionsToDisplay} labels`
|
||||
: "Use as color scale"
|
||||
}
|
||||
position="bottom"
|
||||
usePortal={false}
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
data-testclass="colorby"
|
||||
data-testid={`colorby-${metadataField}`}
|
||||
onClick={this.handleColorChange}
|
||||
active={colorAccessor === metadataField}
|
||||
intent={colorAccessor === metadataField ? "primary" : "none"}
|
||||
icon={"tint"}
|
||||
disabled={isTruncated}
|
||||
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>
|
||||
</CategoryFlipperLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import { Flipper, Flipped } from "react-flip-toolkit";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import Value from "./value";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelection: state.categoricalSelection
|
||||
}))
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
renderCategoryItems(optTuples) {
|
||||
const { metadataField, isUserAnno } = this.props;
|
||||
|
||||
return _.map(optTuples, (tuple, i) => {
|
||||
return (
|
||||
<Flipped key={tuple[1]} flipId={tuple[1]}>
|
||||
{flippedProps => (
|
||||
<Value
|
||||
isUserAnno={isUserAnno}
|
||||
optTuples={optTuples}
|
||||
key={tuple[1]}
|
||||
metadataField={metadataField}
|
||||
categoryIndex={tuple[1]}
|
||||
i={i}
|
||||
flippedProps={flippedProps}
|
||||
/>
|
||||
)}
|
||||
</Flipped>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
metadataField,
|
||||
categoricalSelection,
|
||||
children,
|
||||
isExpanded
|
||||
} = this.props;
|
||||
const { isTruncated } = categoricalSelection[metadataField];
|
||||
const cat = categoricalSelection[metadataField];
|
||||
const optTuples = [...cat.categoryValueIndices];
|
||||
const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: globals.maxControlsWidth
|
||||
}}
|
||||
data-testclass="category"
|
||||
data-testid={`category-${metadataField}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div style={{ marginLeft: 26 }}>
|
||||
<Flipper spring="veryGentle" flipKey={optTuplesAsKey}>
|
||||
{isExpanded ? this.renderCategoryItems(optTuples) : null}
|
||||
</Flipper>
|
||||
</div>
|
||||
<div>
|
||||
{isExpanded && isTruncated ? (
|
||||
<p style={{ paddingLeft: 15 }}>... truncated list ...</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Category;
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from "react";
|
||||
import { InputGroup, MenuItem, Keys } from "@blueprintjs/core";
|
||||
import { Suggest } from "@blueprintjs/select";
|
||||
import fuzzysort from "fuzzysort";
|
||||
|
||||
export default class LabelInput extends React.PureComponent {
|
||||
/*
|
||||
Input widget for text labels, which acts like an InputGroup, but will also
|
||||
accept a suggestion list (of labels), with sublime-like suggest search.
|
||||
|
||||
Properties:
|
||||
|
||||
* labelSuggestions -- array of suggested lables. May be array of string or
|
||||
other objects. If array of objects, specify `labelKey`. If null, suggestion
|
||||
mode is disabled.
|
||||
* onSelect -- optional, callback upon selection of item from labelSuggestions.
|
||||
(label) => void
|
||||
* onChange -- optional, callback upon change in text input. (label) => void
|
||||
* label -- component value, for controlled use
|
||||
* newLabelMessage -- text to display when user enters a label not in labelSuggestions
|
||||
(not used if suggestion mode disabled)
|
||||
* inputProps -- will be passed to InputGroup
|
||||
* popoverProps -- will be passed to <Suggest>
|
||||
*/
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const { label } = props;
|
||||
const query = label || "";
|
||||
const queryResults = this.filterLabels(query);
|
||||
this.state = {
|
||||
query,
|
||||
queryResults
|
||||
};
|
||||
}
|
||||
|
||||
handleQueryChange = (query, event) => {
|
||||
// https://github.com/palantir/blueprint/issues/2983
|
||||
if (!event) return;
|
||||
|
||||
const queryResults = this.filterLabels(query);
|
||||
this.setState({
|
||||
query,
|
||||
queryResults
|
||||
});
|
||||
|
||||
const { onChange } = this.props;
|
||||
if (onChange) onChange(query, event);
|
||||
};
|
||||
|
||||
handleItemSelect = (item, event) => {
|
||||
/* only report the select if not already reported via onChange() */
|
||||
const { target } = item;
|
||||
const { query } = this.state;
|
||||
const { onSelect } = this.props;
|
||||
if (target !== query && onSelect) onSelect(target, event);
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
/*
|
||||
prevent these events from propagating to containing form/dialog
|
||||
and causing further side effects (eg, closing dialog, submitting
|
||||
form, etc).
|
||||
*/
|
||||
const { keyCode } = e;
|
||||
if (keyCode === Keys.ENTER || keyCode === Keys.ESCAPE) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (keyCode === Keys.ESCAPE) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
const { onChange } = this.props;
|
||||
if (onChange) onChange(e.target.value);
|
||||
};
|
||||
|
||||
renderLabelSuggestion = (queryResult, { handleClick, modifiers }) => {
|
||||
if (queryResult.newLabel) {
|
||||
const { newLabelMessage } = this.props;
|
||||
return (
|
||||
<MenuItem
|
||||
icon="flag"
|
||||
active={modifiers.active}
|
||||
disabled={modifiers.disabled}
|
||||
key={queryResult.target}
|
||||
onClick={handleClick}
|
||||
text={<em>{queryResult.target}</em>}
|
||||
label={newLabelMessage || "New label"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
active={modifiers.active}
|
||||
disabled={modifiers.disabled}
|
||||
key={queryResult.target}
|
||||
onClick={handleClick}
|
||||
text={queryResult.target}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* maxinum number of suggestions */
|
||||
static QueryResultLimit = 100;
|
||||
|
||||
filterLabels(query) {
|
||||
const { labelSuggestions } = this.props;
|
||||
if (!labelSuggestions) return [];
|
||||
|
||||
/* empty query is wildcard */
|
||||
if (query === "") {
|
||||
return labelSuggestions.slice(0, LabelInput.QueryResultLimit).map(l => ({
|
||||
target: l,
|
||||
score: -10000
|
||||
}));
|
||||
}
|
||||
|
||||
/* else, do a fuzzy query */
|
||||
const options = {
|
||||
limit: LabelInput.QueryResultLimit,
|
||||
threshold: -10000 // don't return bad results
|
||||
};
|
||||
let queryResults = fuzzysort.go(query, labelSuggestions, options);
|
||||
/* exact match will always be first in list */
|
||||
if (query !== "" && queryResults[0]?.target !== query)
|
||||
queryResults = [{ target: query, newLabel: true }, ...queryResults];
|
||||
|
||||
return queryResults;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const { labelSuggestions, label, autoFocus = true } = props;
|
||||
const suggestEnabled = !!labelSuggestions && labelSuggestions.length > 0;
|
||||
|
||||
if (!suggestEnabled) {
|
||||
return (
|
||||
<InputGroup
|
||||
autoFocus={autoFocus}
|
||||
{...props.inputProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
value={label}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const popoverProps = {
|
||||
minimal: true,
|
||||
...props.popoverProps
|
||||
};
|
||||
const inputProps = {
|
||||
...props.inputProps,
|
||||
autoFocus: false
|
||||
};
|
||||
const { queryResults } = this.state;
|
||||
return (
|
||||
<>
|
||||
<Suggest
|
||||
fill
|
||||
inputValueRenderer={i => i.target}
|
||||
items={queryResults}
|
||||
itemRenderer={this.renderLabelSuggestion}
|
||||
onItemSelect={this.handleItemSelect}
|
||||
query={label}
|
||||
onQueryChange={this.handleQueryChange}
|
||||
popoverProps={popoverProps}
|
||||
inputProps={inputProps}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { Colors } from "@blueprintjs/core";
|
||||
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
|
||||
export function isLabelErroneous(label, metadataField, ontology, schema) {
|
||||
/*
|
||||
return false if this is a LEGAL/acceptable category name or NULL/empty string,
|
||||
or return an error type.
|
||||
*/
|
||||
|
||||
/* allow empty string */
|
||||
if (label === "") return false;
|
||||
|
||||
/* check for label syntax errors, but allow terms in ontology */
|
||||
const termInOntology = ontology?.termSet.has(label) ?? false;
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(label);
|
||||
if (error && !termInOntology) return error;
|
||||
|
||||
/* disallow duplicates */
|
||||
const { obsByName } = schema.annotations;
|
||||
if (obsByName[metadataField].categories.indexOf(label) !== -1)
|
||||
return "duplicate";
|
||||
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
}
|
||||
|
||||
/* all other errors - map code to human error message */
|
||||
const errorMessageMap = {
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Name must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters":
|
||||
"Only alphanumeric and special characters (-_.) allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
|
||||
export function labelPrompt(err, prolog, epilog) {
|
||||
let errPrompt = null;
|
||||
if (err) {
|
||||
let errMsg = errorMessageMap[err] ?? "error";
|
||||
errMsg = errMsg[0].toLowerCase() + errMsg.slice(1);
|
||||
errPrompt = (
|
||||
<span
|
||||
style={{
|
||||
marginTop: 7,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{errMsg}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{prolog}
|
||||
{err ? " - " : null}
|
||||
{errPrompt}
|
||||
{epilog}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import {
|
||||
Popover,
|
||||
PopoverInteractionKind,
|
||||
Position,
|
||||
Classes
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
@connect()
|
||||
class Occupancy extends React.PureComponent {
|
||||
_WIDTH = 100;
|
||||
|
||||
_HEIGHT = 11;
|
||||
|
||||
createHistogram = () => {
|
||||
/*
|
||||
Knowing that colorScale is based off continous data,
|
||||
createHistogram fetches the continous data in relation to the cells releveant to the catagory value.
|
||||
It then seperates that data into 50 bins for drawing the mini-histogram
|
||||
*/
|
||||
const {
|
||||
world,
|
||||
metadataField,
|
||||
colorAccessor,
|
||||
category,
|
||||
categoryIndex
|
||||
} = this.props;
|
||||
|
||||
if (!this.canvas) return;
|
||||
|
||||
const groupBy = world.obsAnnotations.col(metadataField);
|
||||
|
||||
const col =
|
||||
world.obsAnnotations.col(colorAccessor) ||
|
||||
world.varData.col(colorAccessor);
|
||||
|
||||
const range = col.summarize();
|
||||
|
||||
const histogramMap = col.histogram(
|
||||
50,
|
||||
[range.min, range.max],
|
||||
groupBy
|
||||
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
|
||||
|
||||
const categoryValue = category.categoryValues[categoryIndex];
|
||||
const bins = histogramMap.has(categoryValue)
|
||||
? histogramMap.get(categoryValue)
|
||||
: new Array(50).fill(0);
|
||||
|
||||
const xScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, bins.length])
|
||||
.range([0, this._WIDTH]);
|
||||
|
||||
const largestBin = Math.max(...bins);
|
||||
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, largestBin])
|
||||
.range([0, this._HEIGHT]);
|
||||
|
||||
const ctx = this.canvas.getContext("2d");
|
||||
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
let x;
|
||||
let y;
|
||||
|
||||
const rectWidth = this._WIDTH / bins.length;
|
||||
|
||||
for (let i = 0, { length } = bins; i < length; i += 1) {
|
||||
x = xScale(i);
|
||||
y = yScale(bins[i]);
|
||||
ctx.fillRect(x, this._HEIGHT - y, rectWidth, y);
|
||||
}
|
||||
};
|
||||
|
||||
createOccupancyStack = () => {
|
||||
/*
|
||||
Knowing that the color scale is based off of catagorical data,
|
||||
createOccupancyStack obtains a map showing the number if cells per colored value
|
||||
Using the colorScale a stack of colored bars is drawn representing the map
|
||||
*/
|
||||
const {
|
||||
world,
|
||||
metadataField,
|
||||
colorAccessor,
|
||||
category,
|
||||
categoryIndex,
|
||||
schema,
|
||||
colorScale
|
||||
} = this.props;
|
||||
|
||||
const ctx = this.canvas?.getContext("2d");
|
||||
|
||||
if (!ctx) return;
|
||||
|
||||
const groupBy = world.obsAnnotations.col(metadataField);
|
||||
const occupancyMap = world.obsAnnotations
|
||||
.col(colorAccessor)
|
||||
.histogram(groupBy);
|
||||
|
||||
const occupancy = occupancyMap.get(category.categoryValues[categoryIndex]);
|
||||
|
||||
if (occupancy && occupancy.size > 0) {
|
||||
// not all categories have occupancy, so occupancy may be undefined.
|
||||
const x = d3
|
||||
.scaleLinear()
|
||||
/* get all the keys d[1] as an array, then find the sum */
|
||||
.domain([0, d3.sum(Array.from(occupancy.values()))])
|
||||
.range([0, this._WIDTH]);
|
||||
const categories =
|
||||
schema.annotations.obsByName[colorAccessor]?.categories;
|
||||
|
||||
let currentOffset = 0;
|
||||
const dfColumn = world.obsAnnotations.col(colorAccessor);
|
||||
const categoryValues = dfColumn.summarize().categories;
|
||||
|
||||
let o;
|
||||
let scaledValue;
|
||||
let value;
|
||||
|
||||
for (let i = 0, { length } = categoryValues; i < length; i += 1) {
|
||||
value = categoryValues[i];
|
||||
o = occupancy.get(value);
|
||||
scaledValue = x(o);
|
||||
ctx.fillStyle = o
|
||||
? colorScale(categories.indexOf(value))
|
||||
: "rgb(255,255,255)";
|
||||
ctx.fillRect(currentOffset, 0, o ? scaledValue : 0, this._HEIGHT);
|
||||
currentOffset += o ? scaledValue : 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
colorAccessor,
|
||||
categoricalSelection,
|
||||
category,
|
||||
categoryIndex
|
||||
} = this.props;
|
||||
|
||||
const { canvas } = this;
|
||||
if (canvas)
|
||||
canvas.getContext("2d").clearRect(0, 0, this._WIDTH, this._HEIGHT);
|
||||
|
||||
const colorByIsCatagoricalData = !!categoricalSelection[colorAccessor];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER_TARGET_ONLY}
|
||||
hoverOpenDelay={1500}
|
||||
hoverCloseDelay={200}
|
||||
position={Position.LEFT}
|
||||
modifiers={{
|
||||
preventOverflow: { enabled: false },
|
||||
hide: { enabled: false }
|
||||
}}
|
||||
lazy
|
||||
usePortal
|
||||
disabled={colorByIsCatagoricalData}
|
||||
popoverClassName={Classes.POPOVER_CONTENT_SIZING}
|
||||
>
|
||||
<canvas
|
||||
className="bp3-popover-targer"
|
||||
style={{
|
||||
marginRight: 5,
|
||||
width: this._WIDTH,
|
||||
height: this._HEIGHT,
|
||||
borderBottom: colorByIsCatagoricalData
|
||||
? ""
|
||||
: "solid rgb(230, 230, 230) 0.25px"
|
||||
}}
|
||||
width={this._WIDTH}
|
||||
height={this._HEIGHT}
|
||||
ref={ref => {
|
||||
this.canvas = ref;
|
||||
if (colorByIsCatagoricalData) this.createOccupancyStack();
|
||||
else this.createHistogram();
|
||||
}}
|
||||
/>
|
||||
<div key="text" style={{ fontFamily: "Roboto", fontSize: "14px" }}>
|
||||
<p style={{ margin: "0" }}>
|
||||
This histograms shows the distribution of{" "}
|
||||
<strong>{colorAccessor}</strong> within{" "}
|
||||
<strong>{category.categoryValues[categoryIndex]}</strong>.
|
||||
<br />
|
||||
<br />
|
||||
The x axis is the same for each histogram, while the y axis is
|
||||
scaled to the largest bin within this histogram instead of the
|
||||
largest bin within the whole category.
|
||||
</p>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Occupancy;
|
||||
@@ -1,7 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
export default values =>
|
||||
Object.keys(values).sort((a, b) => {
|
||||
const textA = a.toUpperCase();
|
||||
const textB = b.toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||
});
|
||||
@@ -1,87 +1,578 @@
|
||||
// jshint esversion: 6
|
||||
import { connect } from "react-redux";
|
||||
import React from "react";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap,
|
||||
colorScale: state.controls.colorScale,
|
||||
colorAccessor: state.controls.colorAccessor
|
||||
}))
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Position,
|
||||
PopoverInteractionKind,
|
||||
Tooltip
|
||||
} from "@blueprintjs/core";
|
||||
import Occupancy from "./occupancy";
|
||||
import * as globals from "../../globals";
|
||||
import styles from "./categorical.css";
|
||||
import AnnoDialog from "./annoDialog";
|
||||
import LabelInput from "./labelInput";
|
||||
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
import { labelPrompt, isLabelErroneous } from "./labelUtil";
|
||||
|
||||
/* this is defined outside of the class so we can use it in connect() */
|
||||
function _currentLabel(ownProps, categoricalSelection) {
|
||||
const { metadataField, categoryIndex } = ownProps;
|
||||
return String(
|
||||
categoricalSelection[metadataField].categoryValues[categoryIndex]
|
||||
).valueOf();
|
||||
}
|
||||
|
||||
@connect((state, ownProps) => {
|
||||
const { pointDilation, categoricalSelection } = state;
|
||||
const { metadataField, categoryField } = ownProps;
|
||||
const isDilated =
|
||||
pointDilation.metadataField === metadataField &&
|
||||
pointDilation.categoryField ===
|
||||
_currentLabel(ownProps, categoricalSelection);
|
||||
return {
|
||||
categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
schema: state.world?.schema,
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter,
|
||||
ontology: state.ontology,
|
||||
isDilated
|
||||
};
|
||||
})
|
||||
class CategoryValue extends React.Component {
|
||||
toggleOff() {
|
||||
const { dispatch, metadataField, value } = this.props;
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
editedLabelText: this.currentLabel()
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelection, metadataField, categoryIndex } = this.props;
|
||||
if (
|
||||
prevProps.categoricalSelection !== categoricalSelection ||
|
||||
prevProps.metadataField !== metadataField ||
|
||||
prevProps.categoryIndex !== categoryIndex
|
||||
) {
|
||||
this.setState({
|
||||
editedLabelText: this.currentLabel()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleDeleteValue = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const label = this.getLabel();
|
||||
|
||||
dispatch({
|
||||
type: "annotation: delete label",
|
||||
metadataField,
|
||||
label
|
||||
});
|
||||
};
|
||||
|
||||
handleAddCurrentSelectionToThisLabel = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
const label = this.getLabel();
|
||||
dispatch({
|
||||
type: "annotation: label current cell selection",
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
label
|
||||
});
|
||||
};
|
||||
|
||||
handleEditValue = e => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
const { editedLabelText } = this.state;
|
||||
const label = this.getLabel();
|
||||
this.cancelEditMode();
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
editedLabel: editedLabelText,
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
label
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleCreateArbitraryLabel = txt => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
const label = this.getLabel();
|
||||
this.cancelEditMode();
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
metadataField,
|
||||
editedLabel: txt,
|
||||
categoryIndex,
|
||||
label
|
||||
});
|
||||
};
|
||||
|
||||
labelNameError = name => {
|
||||
const { metadataField, ontology, schema } = this.props;
|
||||
if (name === this.currentLabel()) return false;
|
||||
return isLabelErroneous(name, metadataField, ontology, schema);
|
||||
};
|
||||
|
||||
instruction = label => {
|
||||
return labelPrompt(this.labelNameError(label), "New, unique label", ":");
|
||||
};
|
||||
|
||||
activateEditLabelMode = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: activate edit label mode",
|
||||
metadataField,
|
||||
categoryIndex
|
||||
});
|
||||
};
|
||||
|
||||
cancelEditMode = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
this.setState({
|
||||
editedLabelText: this.currentLabel()
|
||||
});
|
||||
dispatch({
|
||||
type: "annotation: cancel edit label mode",
|
||||
metadataField,
|
||||
categoryIndex
|
||||
});
|
||||
};
|
||||
|
||||
toggleOff = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter deselect",
|
||||
metadataField,
|
||||
value
|
||||
categoryIndex
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleOn() {
|
||||
const { dispatch, metadataField, value } = this.props;
|
||||
shouldComponentUpdate = (nextProps, nextState) => {
|
||||
/*
|
||||
Checks to see if at least one of the following changed:
|
||||
* world state
|
||||
* the color accessor (what is currently being colored by)
|
||||
* if this catagorical value's selection status has changed
|
||||
* the crossfilter (ie, global selection state)
|
||||
|
||||
If and only if true, update the component
|
||||
*/
|
||||
const { props, state } = this;
|
||||
const { metadataField, categoryIndex, categoricalSelection } = props;
|
||||
const { categoricalSelection: newCategoricalSelection } = nextProps;
|
||||
|
||||
const valueSelectionChange =
|
||||
categoricalSelection[metadataField].categoryValueSelected[
|
||||
categoryIndex
|
||||
] !==
|
||||
newCategoricalSelection[metadataField].categoryValueSelected[
|
||||
categoryIndex
|
||||
];
|
||||
|
||||
const worldChange = props.world !== nextProps.world;
|
||||
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
|
||||
const annotationsChange = props.annotations !== nextProps.annotations;
|
||||
const crossfilterChange =
|
||||
props.isUserAnno && props.crossfilter !== nextProps.crossfilter;
|
||||
const editingLabel = state.editedLabelText !== nextState.editedLabelText;
|
||||
const dilationChange = props.isDilated !== nextProps.isDilated;
|
||||
|
||||
return (
|
||||
valueSelectionChange ||
|
||||
worldChange ||
|
||||
colorAccessorChange ||
|
||||
annotationsChange ||
|
||||
crossfilterChange ||
|
||||
editingLabel ||
|
||||
dilationChange
|
||||
);
|
||||
};
|
||||
|
||||
toggleOn = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter select",
|
||||
metadataField,
|
||||
value
|
||||
categoryIndex
|
||||
});
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "category value mouse hover start",
|
||||
metadataField,
|
||||
categoryIndex
|
||||
});
|
||||
};
|
||||
|
||||
handleMouseExit = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "category value mouse hover end",
|
||||
metadataField,
|
||||
categoryIndex
|
||||
});
|
||||
};
|
||||
|
||||
handleTextChange = text => {
|
||||
this.setState({ editedLabelText: text });
|
||||
};
|
||||
|
||||
handleChoice = e => {
|
||||
/* Blueprint Suggest format */
|
||||
this.setState({ editedLabelText: e.target });
|
||||
};
|
||||
|
||||
getLabel = () => {
|
||||
const { metadataField, categoryIndex, categoricalSelection } = this.props;
|
||||
const category = categoricalSelection[metadataField];
|
||||
const label = category.categoryValues[categoryIndex];
|
||||
|
||||
return label;
|
||||
};
|
||||
|
||||
currentLabel() {
|
||||
const { categoricalSelection } = this.props;
|
||||
return _currentLabel(this.props, categoricalSelection);
|
||||
}
|
||||
|
||||
isAddCurrentSelectionDisabled(category, value) {
|
||||
/*
|
||||
disable "add current selection to label", if one of the following is true:
|
||||
1. no cells are selected
|
||||
2. all currently selected cells already have this label, on this category
|
||||
*/
|
||||
const { crossfilter, world } = this.props;
|
||||
|
||||
// 1. no cells selected?
|
||||
if (crossfilter.countSelected() === 0) {
|
||||
return true;
|
||||
}
|
||||
// 2. all selected cells already have the label
|
||||
const mask = crossfilter.allSelectedMask();
|
||||
if (
|
||||
AnnotationsHelpers.allHaveLabelByMask(
|
||||
world.obsAnnotations,
|
||||
category,
|
||||
value,
|
||||
mask
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// else, don't disable
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
categoricalAsBooleansMap,
|
||||
categoricalSelection,
|
||||
metadataField,
|
||||
count,
|
||||
value,
|
||||
categoryIndex,
|
||||
colorAccessor,
|
||||
colorScale,
|
||||
i
|
||||
i,
|
||||
schema,
|
||||
isUserAnno,
|
||||
annotations,
|
||||
ontology,
|
||||
// flippedProps is potentially brittle, their docs want {...flippedProps} on our div,
|
||||
// our lint doesn't like jsx spread, we are version pinned to prevent api change on their part
|
||||
flippedProps,
|
||||
isDilated
|
||||
} = this.props;
|
||||
const ontologyEnabled = ontology?.enabled ?? false;
|
||||
|
||||
if (!categoricalAsBooleansMap) return null;
|
||||
const { editedLabelText } = this.state;
|
||||
|
||||
if (!categoricalSelection) return null;
|
||||
|
||||
const category = categoricalSelection[metadataField];
|
||||
const selected = category.categoryValueSelected[categoryIndex];
|
||||
const count = category.categoryValueCounts[categoryIndex];
|
||||
const value = category.categoryValues[categoryIndex];
|
||||
const displayString = this.currentLabel();
|
||||
|
||||
const selected = categoricalAsBooleansMap[metadataField][value];
|
||||
/* this is the color scale, so add swatches below */
|
||||
const c = metadataField === colorAccessor;
|
||||
const isColorBy = metadataField === colorAccessor;
|
||||
let categories = null;
|
||||
|
||||
if (isColorBy && schema) {
|
||||
categories = schema.annotations.obsByName[colorAccessor]?.categories;
|
||||
}
|
||||
|
||||
let truncatedString = null;
|
||||
|
||||
if (
|
||||
colorAccessor &&
|
||||
!isColorBy &&
|
||||
displayString.length > globals.categoryLabelDisplayStringShortLength
|
||||
) {
|
||||
truncatedString = `${displayString.slice(
|
||||
0,
|
||||
globals.categoryLabelDisplayStringShortLength / 2
|
||||
)}…${displayString.slice(
|
||||
-globals.categoryLabelDisplayStringShortLength / 2
|
||||
)}`;
|
||||
} else if (
|
||||
displayString.length > globals.categoryLabelDisplayStringLongLength
|
||||
) {
|
||||
truncatedString = `${displayString.slice(
|
||||
0,
|
||||
globals.categoryLabelDisplayStringLongLength / 2
|
||||
)}…${displayString.slice(
|
||||
-globals.categoryLabelDisplayStringLongLength / 2
|
||||
)}`;
|
||||
}
|
||||
|
||||
const editModeActive =
|
||||
isUserAnno &&
|
||||
annotations.labelEditable.category === metadataField &&
|
||||
annotations.isEditingLabelName &&
|
||||
annotations.labelEditable.label === categoryIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-flip-config={flippedProps["data-flip-config"]}
|
||||
data-flip-id={flippedProps["data-flip-id"]}
|
||||
data-portal-key={flippedProps["data-portal-key"]}
|
||||
className={
|
||||
/* This code is to change the styles on centroid label hover is causing over-rendering */
|
||||
`${styles.value}${isDilated ? ` ${styles.hover}` : ""}`
|
||||
}
|
||||
data-testclass="categorical-row"
|
||||
style={{
|
||||
padding: "4px 7px",
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between"
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "2px",
|
||||
borderRadius: "2px"
|
||||
}}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseExit}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
userSelect: "none"
|
||||
userSelect: "none",
|
||||
width: globals.leftSidebarWidth - 145,
|
||||
display: "flex",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
>
|
||||
<label className="bp3-control bp3-checkbox">
|
||||
<input
|
||||
onChange={
|
||||
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)
|
||||
}
|
||||
checked={selected}
|
||||
type="checkbox"
|
||||
<div style={{ display: "flex", alignItems: "baseline" }}>
|
||||
<label className="bp3-control bp3-checkbox" style={{ margin: 0 }}>
|
||||
<input
|
||||
onChange={selected ? this.toggleOff : this.toggleOn}
|
||||
data-testclass="categorical-value-select"
|
||||
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
|
||||
checked={selected}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span
|
||||
className="bp3-control-indicator"
|
||||
onMouseEnter={this.handleMouseExit}
|
||||
onMouseLeave={this.handleMouseEnter}
|
||||
/>
|
||||
</label>
|
||||
<Tooltip
|
||||
content={displayString}
|
||||
disabled={truncatedString === null}
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelayQuick}
|
||||
>
|
||||
<span
|
||||
data-testid={`categorical-value-${metadataField}-${displayString}`}
|
||||
data-testclass="categorical-value"
|
||||
style={{
|
||||
color:
|
||||
displayString === globals.unassignedCategoryLabel
|
||||
? "#ababab"
|
||||
: "black",
|
||||
fontStyle:
|
||||
displayString === globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "normal",
|
||||
display: "inline-block",
|
||||
overflow: "hidden",
|
||||
lineHeight: "1.1em",
|
||||
height: "1.1em",
|
||||
wordBreak: "break-all",
|
||||
verticalAlign: "middle"
|
||||
}}
|
||||
>
|
||||
{truncatedString || displayString}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{editModeActive ? (
|
||||
<div>
|
||||
<AnnoDialog
|
||||
isActive={editModeActive}
|
||||
inputProps={{
|
||||
"data-testid": `${metadataField}:edit-label-name-dialog`
|
||||
}}
|
||||
primaryButtonProps={{
|
||||
"data-testid": `${metadataField}:${displayString}:submit-label-edit`
|
||||
}}
|
||||
title="Edit label"
|
||||
instruction={this.instruction(editedLabelText)}
|
||||
cancelTooltipContent="Close this dialog without editing label text."
|
||||
primaryButtonText="Change label text"
|
||||
text={editedLabelText}
|
||||
categoryToDuplicate={null}
|
||||
validationError={this.labelNameError(editedLabelText)}
|
||||
handleSubmit={this.handleEditValue}
|
||||
handleCancel={this.cancelEditMode}
|
||||
annoInput={
|
||||
<LabelInput
|
||||
label={editedLabelText}
|
||||
labelSuggestions={ontologyEnabled ? ontology.terms : null}
|
||||
onChange={this.handleTextChange}
|
||||
onSelect={this.handleTextChange}
|
||||
inputProps={{
|
||||
"data-testid": `${metadataField}:${displayString}:edit-label-name`,
|
||||
leftIcon: "tag",
|
||||
intent: "none",
|
||||
autoFocus: true
|
||||
}}
|
||||
/>
|
||||
}
|
||||
annoSelect={null}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
|
||||
<Occupancy
|
||||
category={category}
|
||||
{...this.props} // eslint-disable-line react/jsx-props-no-spreading
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<span
|
||||
data-testclass="categorical-value-count"
|
||||
data-testid={`categorical-value-count-${metadataField}-${displayString}`}
|
||||
style={{
|
||||
color:
|
||||
displayString === globals.unassignedCategoryLabel
|
||||
? "#ababab"
|
||||
: "black",
|
||||
fontStyle:
|
||||
displayString === globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "auto"
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
|
||||
<svg
|
||||
display={isColorBy && categories ? "auto" : "none"}
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
width: 11,
|
||||
height: 11,
|
||||
backgroundColor:
|
||||
isColorBy && categories
|
||||
? colorScale(categories.indexOf(value))
|
||||
: "inherit"
|
||||
}}
|
||||
/>
|
||||
<span className="bp3-control-indicator" />
|
||||
{value}
|
||||
</label>
|
||||
{isUserAnno ? (
|
||||
<span
|
||||
onMouseEnter={this.handleMouseExit}
|
||||
onMouseLeave={this.handleMouseEnter}
|
||||
>
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER}
|
||||
boundary="window"
|
||||
position={Position.RIGHT_TOP}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="plus"
|
||||
data-testclass="handleAddCurrentSelectionToThisLabel"
|
||||
data-testid={`${metadataField}:${displayString}:add-current-selection-to-this-label`}
|
||||
onClick={this.handleAddCurrentSelectionToThisLabel}
|
||||
text={
|
||||
<span>
|
||||
Re-label currently selected cells as
|
||||
<span
|
||||
style={{
|
||||
fontStyle:
|
||||
displayString ===
|
||||
globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "auto"
|
||||
}}
|
||||
>
|
||||
{` ${displayString}`}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
disabled={this.isAddCurrentSelectionDisabled(
|
||||
metadataField,
|
||||
value
|
||||
)}
|
||||
/>
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="edit"
|
||||
text="Edit this label's name"
|
||||
data-testclass="handleEditValue"
|
||||
data-testid={`${metadataField}:${displayString}:edit-label`}
|
||||
onClick={this.activateEditLabelMode}
|
||||
disabled={annotations.isEditingLabelName}
|
||||
/>
|
||||
) : null}
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="delete"
|
||||
intent="danger"
|
||||
data-testclass="handleDeleteValue"
|
||||
data-testid={`${metadataField}:${displayString}:delete-label`}
|
||||
onClick={this.handleDeleteValue}
|
||||
text={`Delete this label, and reassign all cells to type '${globals.unassignedCategoryLabel}'`}
|
||||
/>
|
||||
) : null}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
marginLeft: 0,
|
||||
position: "relative",
|
||||
top: -1,
|
||||
minHeight: 16
|
||||
}}
|
||||
data-testclass="seeActions"
|
||||
data-testid={`${metadataField}:${displayString}:see-actions`}
|
||||
icon="more"
|
||||
small
|
||||
minimal
|
||||
/>
|
||||
</Popover>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<span>{count}</span>
|
||||
<svg
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
width: 11,
|
||||
height: 11,
|
||||
backgroundColor: c ? colorScale(value) : "inherit"
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,83 +2,107 @@
|
||||
/* 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 { Button } from "@blueprintjs/core";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import HistogramBrush from "../brushableHistogram";
|
||||
|
||||
@connect(state => ({
|
||||
ranges: _.get(state.controls.world, "summary.obs", null),
|
||||
metadata: _.get(state.controls.world, "obsAnnotations", null),
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null),
|
||||
schema: _.get(state.controls.world, "schema", null)
|
||||
obsAnnotations: state.world?.obsAnnotations,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
schema: state.world?.schema
|
||||
}))
|
||||
class Continuous extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.hasContinuous = false;
|
||||
this.continuousChecked = false;
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidUpdate() {}
|
||||
|
||||
handleColorAction(key) {
|
||||
class Continuous extends React.PureComponent {
|
||||
handleColorAction = key => {
|
||||
return () => {
|
||||
const { dispatch, ranges } = this.props;
|
||||
const { dispatch, obsAnnotations } = this.props;
|
||||
const summary = obsAnnotations.col(key).summarize();
|
||||
dispatch({
|
||||
type: "color by continuous metadata",
|
||||
colorAccessor: key,
|
||||
rangeMaxForColorAccessor: ranges[key].range.max
|
||||
rangeForColorAccessor: summary
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
static renderIsStillLoading(zebra, key) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
padding: globals.leftSidebarSectionPadding,
|
||||
backgroundColor: zebra % 2 === 0 ? globals.lightestGrey : "white"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
justifyItems: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 30 }} />
|
||||
<div style={{ display: "flex", alignSelf: "center" }}>
|
||||
<span style={{ fontStyle: "italic" }}>{key}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end"
|
||||
}}
|
||||
>
|
||||
<Button minimal loading intent="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { ranges, 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 */
|
||||
}
|
||||
const { obsAnnotations, schema } = this.props;
|
||||
|
||||
const obsIndex = schema.annotations.obs.index;
|
||||
const allContinuousNames = schema.annotations.obs.columns
|
||||
.filter(col => col.type === "int32" || col.type === "float32")
|
||||
.filter(col => col.name !== obsIndex)
|
||||
.map(col => col.name);
|
||||
|
||||
/* initial value for iterator to simulate index, ranges is an object */
|
||||
let zebra = -1;
|
||||
let zebra = 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.hasContinuous ? (
|
||||
<p
|
||||
style={Object.assign({}, globals.leftSidebarSectionHeading, {
|
||||
marginTop: 40,
|
||||
paddingLeft: globals.leftSidebarSectionPadding
|
||||
})}
|
||||
>
|
||||
Continuous metadata
|
||||
</p>
|
||||
) : null}
|
||||
{_.map(ranges, (value, key) => {
|
||||
const isColorField = key.includes("color") || key.includes("Color");
|
||||
zebra += 1;
|
||||
if (value.range && key !== "name" && !isColorField) {
|
||||
{allContinuousNames.map(key => {
|
||||
if (!obsAnnotations.hasCol(key)) {
|
||||
// still loading!
|
||||
zebra += 1;
|
||||
return Continuous.renderIsStillLoading(zebra, key);
|
||||
}
|
||||
|
||||
// data loaded and available
|
||||
const summary = obsAnnotations.col(key).summarize();
|
||||
const nonFiniteExtent =
|
||||
summary.min === undefined ||
|
||||
summary.max === undefined ||
|
||||
Number.isNaN(summary.min) ||
|
||||
Number.isNaN(summary.max);
|
||||
if (!summary.categorical && !nonFiniteExtent) {
|
||||
zebra += 1;
|
||||
return (
|
||||
<HistogramBrush
|
||||
key={key}
|
||||
field={key}
|
||||
isObs
|
||||
zebra={zebra % 2 === 0}
|
||||
fieldValues={obsAnnotations}
|
||||
ranges={value.range}
|
||||
handleColorAction={this.handleColorAction(key).bind(this)}
|
||||
ranges={summary}
|
||||
handleColorAction={this.handleColorAction(key)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
import * as d3 from "d3";
|
||||
import styles from "./parallelCoordinates.css";
|
||||
import { yAxis, brushstart } from "./util";
|
||||
|
||||
const drawAxes = (
|
||||
svg,
|
||||
ctx,
|
||||
dimensions,
|
||||
xscale,
|
||||
height,
|
||||
width,
|
||||
handleBrushAction,
|
||||
handleColorAction
|
||||
) => {
|
||||
/*****************************************
|
||||
******************************************
|
||||
Handles a brush event, toggling the display of foreground lines.
|
||||
******************************************
|
||||
******************************************/
|
||||
|
||||
function brush() {
|
||||
const actives = [];
|
||||
svg
|
||||
.selectAll(".parcoords_axis .parcoords_brush")
|
||||
.filter(() => d3.brushSelection(this))
|
||||
.each(d => {
|
||||
actives.push({
|
||||
dimension: d,
|
||||
extent: d3.brushSelection(this)
|
||||
});
|
||||
});
|
||||
/* fire action, with selected dimensions & their values */
|
||||
handleBrushAction(actives);
|
||||
}
|
||||
|
||||
const axes = svg
|
||||
.selectAll(".parcoords_axis")
|
||||
.data(dimensions)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", `${styles.axis} parcoords_axis`)
|
||||
.attr("transform", (d, i) => `translate(${xscale(i)})`);
|
||||
|
||||
axes
|
||||
.append("g")
|
||||
.each(d => {
|
||||
const renderAxis =
|
||||
"axis" in d
|
||||
? d.axis.scale(d.scale) // custom axis
|
||||
: yAxis.scale(d.scale); // default axis
|
||||
d3.select(this).call(renderAxis);
|
||||
})
|
||||
.append("text")
|
||||
.on("click", d => {
|
||||
handleColorAction(d.key);
|
||||
})
|
||||
.attr("class", styles.title)
|
||||
.attr("text-anchor", "start")
|
||||
.text(d => ("description" in d ? `${d.description} 🖌️` : `${d.key} 🖌️`));
|
||||
|
||||
// Add and store a brush for each axis.
|
||||
axes
|
||||
.append("g")
|
||||
.attr("class", `${styles.brush} parcoords_brush`)
|
||||
.each(d => {
|
||||
d3.select(this).call(
|
||||
(d.brush = d3
|
||||
.brushY()
|
||||
.extent([[-10, 0], [10, height]])
|
||||
.on("start", brushstart)
|
||||
.on("brush", brush)
|
||||
.on("end", brush))
|
||||
);
|
||||
})
|
||||
.selectAll("rect")
|
||||
.attr("x", -8)
|
||||
.attr("width", 16);
|
||||
|
||||
return axes;
|
||||
};
|
||||
|
||||
export default drawAxes;
|
||||
@@ -2,7 +2,8 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import { interpolateViridis } from "d3-scale-chromatic";
|
||||
import { interpolateCool } from "d3-scale-chromatic";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
// create continuous color legend
|
||||
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
||||
@@ -98,8 +99,8 @@ const continuous = (selectorId, colorscale, colorAccessor) => {
|
||||
};
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
responsive: state.responsive
|
||||
}))
|
||||
class ContinuousLegend extends React.Component {
|
||||
@@ -112,6 +113,7 @@ class ContinuousLegend extends React.Component {
|
||||
const { colorAccessor, responsive, colorScale } = this.props;
|
||||
if (
|
||||
prevProps.colorAccessor !== colorAccessor ||
|
||||
prevProps.colorScale !== colorScale ||
|
||||
prevProps.responsive.height !== responsive.height ||
|
||||
prevProps.responsive.width !== responsive.width
|
||||
) {
|
||||
@@ -121,12 +123,12 @@ class ContinuousLegend extends React.Component {
|
||||
.remove();
|
||||
}
|
||||
|
||||
if (colorAccessor && colorScale) {
|
||||
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(interpolateViridis).domain(colorScale.domain()),
|
||||
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
|
||||
colorAccessor
|
||||
);
|
||||
}
|
||||
@@ -141,7 +143,7 @@ class ContinuousLegend extends React.Component {
|
||||
style={{
|
||||
position: "fixed",
|
||||
display: colorAccessor ? "inherit" : "none",
|
||||
right: 0,
|
||||
right: globals.leftSidebarWidth,
|
||||
top: responsive.height / 2
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
const Logo = props => {
|
||||
const { size } = props;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
|
||||
<rect width="48" height="48" fill="white" />
|
||||
<rect width="48" height="48" fill={globals.logoColor} />
|
||||
<rect x="19" y="19" width="22" height="22" fill="white" />
|
||||
<rect x="24" y="24" width="12" height="12" fill={globals.logoColor} />
|
||||
<rect x="7" y="19" width="7" height="22" fill="white" />
|
||||
<rect x="19" y="7" width="22" height="7" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
@@ -2,7 +2,7 @@ import { Position, Toaster, Intent } from "@blueprintjs/core";
|
||||
|
||||
/** Singleton toaster instance. Create separate instances for different options. */
|
||||
|
||||
const ErrorToastTopCenter = Toaster.create({
|
||||
const ToastTopCenter = Toaster.create({
|
||||
className: "recipe-toaster",
|
||||
position: Position.TOP
|
||||
});
|
||||
@@ -11,14 +11,38 @@ const ErrorToastTopCenter = Toaster.create({
|
||||
A "user" error - eg, bad input
|
||||
*/
|
||||
export const postUserErrorToast = message =>
|
||||
ErrorToastTopCenter.show({ message, intent: Intent.WARNING });
|
||||
ToastTopCenter.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 =>
|
||||
ToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING });
|
||||
|
||||
/*
|
||||
a hard network error
|
||||
*/
|
||||
export const postNetworkErrorToast = message =>
|
||||
ErrorToastTopCenter.show({
|
||||
ToastTopCenter.show({
|
||||
message,
|
||||
timeout: 30000,
|
||||
intent: Intent.DANGER
|
||||
});
|
||||
|
||||
/*
|
||||
Async message to user
|
||||
*/
|
||||
export const postAsyncSuccessToast = message =>
|
||||
ToastTopCenter.show({
|
||||
message,
|
||||
timeout: 10000,
|
||||
intent: Intent.SUCCESS
|
||||
});
|
||||
|
||||
export const postAsyncFailureToast = message =>
|
||||
ToastTopCenter.show({
|
||||
message,
|
||||
timeout: 10000,
|
||||
intent: Intent.WARNING
|
||||
});
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { Button, Tooltip } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@connect()
|
||||
class CellSetButton extends React.Component {
|
||||
set() {
|
||||
const {
|
||||
differential,
|
||||
crossfilter,
|
||||
dispatch,
|
||||
eitherCellSetOneOrTwo
|
||||
} = this.props;
|
||||
|
||||
const set = _.map(crossfilter.allFiltered(), "name");
|
||||
|
||||
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}`;
|
||||
return (
|
||||
<Tooltip
|
||||
content="Save current selection for differential expression computation"
|
||||
position="top"
|
||||
>
|
||||
<Button
|
||||
style={{ marginRight: 10 }}
|
||||
type="button"
|
||||
onClick={this.set.bind(this)}
|
||||
>
|
||||
{eitherCellSetOneOrTwo}
|
||||
{": "}
|
||||
{differential[cellListName]
|
||||
? `${differential[cellListName].length} cells`
|
||||
: "0 cells"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CellSetButton;
|
||||
@@ -1,100 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { Button, AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import CellSetButton from "./cellSetButtons";
|
||||
|
||||
@connect(state => ({
|
||||
differential: state.differential,
|
||||
world: state.controls.world,
|
||||
crossfilter: state.controls.crossfilter,
|
||||
selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null)
|
||||
}))
|
||||
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"
|
||||
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;
|
||||
@@ -3,28 +3,66 @@
|
||||
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import * as d3 from "d3";
|
||||
import fuzzysort from "fuzzysort";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { Button, Tooltip } from "@blueprintjs/core";
|
||||
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 } from "../framework/toasters";
|
||||
import ExpressionButtons from "./expressionButtons";
|
||||
import {
|
||||
postUserErrorToast,
|
||||
keepAroundErrorToast
|
||||
} from "../framework/toasters";
|
||||
|
||||
import { memoize } from "../../util/dataframe/util";
|
||||
|
||||
const renderGene = (fuzzySortResult, { handleClick, modifiers }) => {
|
||||
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 => {
|
||||
const metadata = _.get(state.controls.world, "obsAnnotations", null);
|
||||
const ranges = _.get(state.controls.world, "summary.obs", null);
|
||||
const initializeRanges = _.get(state.controls.world, "summary.obs");
|
||||
|
||||
return {
|
||||
ranges,
|
||||
metadata,
|
||||
initializeRanges,
|
||||
obsAnnotations: state.world?.obsAnnotations,
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
world: state.controls.world,
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
allGeneNames: state.controls.allGeneNames,
|
||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||
world: state.world,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
differential: state.differential
|
||||
};
|
||||
})
|
||||
@@ -32,95 +70,269 @@ class GeneExpression extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
gene: ""
|
||||
bulkAdd: "",
|
||||
tab: "autosuggest",
|
||||
activeItem: null
|
||||
};
|
||||
}
|
||||
|
||||
keyPress(e) {
|
||||
if (e.keyCode === 13) {
|
||||
this.handleClick();
|
||||
_genesToUpper = listGenes => {
|
||||
// Has to be a Map to preserve index
|
||||
const upperGenes = new Map();
|
||||
for (let i = 0, { length } = listGenes; i < length; i += 1) {
|
||||
upperGenes.set(listGenes[i].toUpperCase(), i);
|
||||
}
|
||||
|
||||
return upperGenes;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/sort-comp
|
||||
_memoGenesToUpper = memoize(this._genesToUpper, arr => arr);
|
||||
|
||||
handleBulkAddClick = () => {
|
||||
const { world, dispatch, userDefinedGenes } = this.props;
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
const { bulkAdd } = this.state;
|
||||
|
||||
/*
|
||||
test:
|
||||
Apod,,, Cd74,, ,,, Foo, Bar-2,,
|
||||
*/
|
||||
if (bulkAdd !== "") {
|
||||
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
||||
if (genes.length === 0) {
|
||||
return keepAroundErrorToast("Must enter a gene name.");
|
||||
}
|
||||
const worldGenes =
|
||||
world.varAnnotations?.col(varIndexName)?.asArray() || [];
|
||||
|
||||
// These gene lists are unique enough where memoization is useless
|
||||
const upperGenes = this._genesToUpper(genes);
|
||||
const upperUserDefinedGenes = this._genesToUpper(userDefinedGenes);
|
||||
|
||||
const upperWorldGenes = this._memoGenesToUpper(worldGenes);
|
||||
|
||||
dispatch({ type: "bulk user defined gene start" });
|
||||
|
||||
Promise.all(
|
||||
[...upperGenes.keys()].map(upperGene => {
|
||||
if (upperUserDefinedGenes.get(upperGene) !== undefined) {
|
||||
return keepAroundErrorToast("That gene already exists");
|
||||
}
|
||||
|
||||
const indexOfGene = upperWorldGenes.get(upperGene);
|
||||
|
||||
if (indexOfGene === undefined) {
|
||||
return keepAroundErrorToast(
|
||||
`${
|
||||
genes[upperGenes.get(upperGene)]
|
||||
} doesn't appear to be a valid gene name.`
|
||||
);
|
||||
}
|
||||
return dispatch(
|
||||
actions.requestUserDefinedGene(worldGenes[indexOfGene])
|
||||
);
|
||||
})
|
||||
).then(
|
||||
() => dispatch({ type: "bulk user defined gene complete" }),
|
||||
() => dispatch({ type: "bulk user defined gene error" })
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({ bulkAdd: "" });
|
||||
return undefined;
|
||||
};
|
||||
|
||||
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 varIndexName = world.schema.annotations.var.index;
|
||||
const geneNames = varAnnotations.col(varIndexName).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() {
|
||||
handleClick(g) {
|
||||
const { world, dispatch, userDefinedGenes } = this.props;
|
||||
const { gene } = this.state;
|
||||
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
if (!g) return;
|
||||
const gene = g.target;
|
||||
if (userDefinedGenes.indexOf(gene) !== -1) {
|
||||
postUserErrorToast("That gene already exists");
|
||||
} else if (userDefinedGenes.length > 15) {
|
||||
} else if (userDefinedGenes.length > globals.maxUserDefinedGenes) {
|
||||
postUserErrorToast(
|
||||
"That's too many genes, you can have at most 15 user defined genes"
|
||||
`That's too many genes, you can have at most ${globals.maxUserDefinedGenes} user defined genes`
|
||||
);
|
||||
} else if (!_.find(world.varAnnotations, { name: gene })) {
|
||||
} else if (
|
||||
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
|
||||
) {
|
||||
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||
} else {
|
||||
dispatch(actions.requestUserDefinedGene(gene));
|
||||
dispatch({
|
||||
type: "user defined gene",
|
||||
data: gene
|
||||
});
|
||||
this.setState({ gene: "" });
|
||||
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" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { world, userDefinedGenes, differential } = this.props;
|
||||
const { gene } = this.state;
|
||||
const {
|
||||
world,
|
||||
userDefinedGenes,
|
||||
userDefinedGenesLoading,
|
||||
differential
|
||||
} = this.props;
|
||||
const varIndexName = world?.schema?.annotations?.var?.index;
|
||||
const varIndex = world?.varAnnotations?.col(varIndexName)?.asArray();
|
||||
const { tab, bulkAdd, activeItem } = this.state;
|
||||
|
||||
// may still be loading!
|
||||
if (!varIndex) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 30
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={Object.assign({}, globals.leftSidebarSectionHeading, {
|
||||
paddingLeft: globals.leftSidebarSectionPadding,
|
||||
margin: 0
|
||||
})}
|
||||
>
|
||||
Selected Genes
|
||||
</p>
|
||||
<div>
|
||||
<div
|
||||
style={{ padding: globals.leftSidebarSectionPadding }}
|
||||
className="bp3-control-group"
|
||||
style={{
|
||||
padding: globals.leftSidebarSectionPadding
|
||||
}}
|
||||
>
|
||||
<div className="bp3-input-group bp3-fill">
|
||||
<input
|
||||
onKeyDown={this.keyPress.bind(this)}
|
||||
onChange={e => {
|
||||
this.setState({ gene: e.target.value });
|
||||
}}
|
||||
value={gene}
|
||||
type="text"
|
||||
className="bp3-input"
|
||||
placeholder="Enter a gene name"
|
||||
style={{ paddingRight: 94 }}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
content="Add a gene to see its expression levels"
|
||||
position="bottom"
|
||||
<Button
|
||||
active={tab === "autosuggest"}
|
||||
style={{ marginRight: 5 }}
|
||||
minimal
|
||||
small
|
||||
data-testid="tab-autosuggest"
|
||||
onClick={() => {
|
||||
this.setState({ tab: "autosuggest" });
|
||||
}}
|
||||
>
|
||||
<Button intent="primary" onClick={this.handleClick.bind(this)}>
|
||||
Add
|
||||
</Button>
|
||||
</Tooltip>
|
||||
Autosuggest genes
|
||||
</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);
|
||||
}}
|
||||
initialContent={<MenuItem disabled text="Enter a gene…" />}
|
||||
inputProps={{ "data-testid": "gene-search" }}
|
||||
inputValueRenderer={() => {
|
||||
return "";
|
||||
}}
|
||||
itemListPredicate={filterGenes}
|
||||
onActiveItemChange={item => this.setState({ activeItem: item })}
|
||||
itemRenderer={renderGene.bind(this)}
|
||||
items={varIndex || ["No genes"]}
|
||||
popoverProps={{ minimal: true }}
|
||||
/>
|
||||
<Button
|
||||
className="bp3-button bp3-intent-primary"
|
||||
data-testid="add-gene"
|
||||
loading={userDefinedGenesLoading}
|
||||
onClick={() => this.handleClick(activeItem)}
|
||||
>
|
||||
Add gene
|
||||
</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}
|
||||
loading={userDefinedGenesLoading}
|
||||
>
|
||||
Add genes
|
||||
</Button>
|
||||
</ControlGroup>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
{world && userDefinedGenes.length > 0
|
||||
? _.map(userDefinedGenes, (geneName, index) => {
|
||||
const values = world.varDataCache[geneName];
|
||||
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={d3.extent(values)}
|
||||
ranges={summary}
|
||||
isUserDefined
|
||||
/>
|
||||
);
|
||||
@@ -128,29 +340,20 @@ class GeneExpression extends React.Component {
|
||||
: 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 annotations = world.varAnnotations[value[0]];
|
||||
const { name } = annotations;
|
||||
const values = world.varDataCache[name];
|
||||
const name = world.varAnnotations.at(value[0], varIndexName);
|
||||
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={d3.extent(values)}
|
||||
ranges={summary}
|
||||
isDiffExp
|
||||
logFoldChange={value[1]}
|
||||
pval={value[2]}
|
||||
|
||||
@@ -1,44 +1,67 @@
|
||||
// jshint esversion: 6
|
||||
const mat4 = require("gl-mat4");
|
||||
|
||||
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
|
||||
import { glPointFlags, glPointSize } from "../../util/glHelpers";
|
||||
|
||||
export default function(regl) {
|
||||
return regl({
|
||||
vert: `
|
||||
precision mediump float;
|
||||
|
||||
attribute vec2 position;
|
||||
attribute vec3 color;
|
||||
attribute float size;
|
||||
attribute float flag;
|
||||
|
||||
uniform float distance;
|
||||
uniform mat4 projection, view;
|
||||
varying vec3 fragColor;
|
||||
uniform mat3 projView;
|
||||
uniform float nPoints;
|
||||
uniform float minViewportDimension;
|
||||
|
||||
varying vec4 fragColor;
|
||||
|
||||
const float zBottom = 0.99;
|
||||
const float zMiddle = 0.;
|
||||
const float zTop = -1.;
|
||||
|
||||
// import getFlags()
|
||||
${glPointFlags}
|
||||
|
||||
// get pointSize()
|
||||
${glPointSize}
|
||||
|
||||
void main() {
|
||||
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
|
||||
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
|
||||
fragColor = color;
|
||||
bool isNaN, isSelected, isHighlight;
|
||||
getFlags(flag, isNaN, isSelected, isHighlight);
|
||||
|
||||
float size = pointSize(nPoints, minViewportDimension, isSelected, isHighlight);
|
||||
gl_PointSize = size * pow(distance, 0.5);
|
||||
|
||||
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
|
||||
vec3 xy = projView * vec3(position, 1.);
|
||||
gl_Position = vec4(xy.xy, z, 1.);
|
||||
|
||||
float alpha = isNaN ? 0.9 : 1.0;
|
||||
fragColor = vec4(color, alpha);
|
||||
}`,
|
||||
|
||||
frag: `
|
||||
precision mediump float;
|
||||
varying vec3 fragColor;
|
||||
varying vec4 fragColor;
|
||||
void main() {
|
||||
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
|
||||
discard;
|
||||
}
|
||||
gl_FragColor = vec4(fragColor, 1);
|
||||
gl_FragColor = fragColor;
|
||||
}`,
|
||||
|
||||
attributes: {
|
||||
position: regl.prop("position"),
|
||||
color: regl.prop("color"),
|
||||
size: regl.prop("size")
|
||||
flag: regl.prop("flag")
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
distance: regl.prop("distance"),
|
||||
view: regl.prop("view"),
|
||||
projection: ({viewportWidth, viewportHeight}) => mat4.perspective([], Math.PI / 2, viewportWidth / viewportHeight, 0.01, 1000)
|
||||
projView: regl.prop("projView"),
|
||||
nPoints: regl.prop("nPoints"),
|
||||
minViewportDimension: regl.prop("minViewportDimension")
|
||||
},
|
||||
|
||||
count: regl.prop("count"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
/* eslint-disable jsx-a11y/mouse-events-have-key-events */
|
||||
import React, { PureComponent } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { categoryLabelDisplayStringLongLength } from "../../../globals";
|
||||
|
||||
export default
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
dilatedValue: state.pointDilation.categoryField,
|
||||
labels: state.centroidLabels.labels
|
||||
}))
|
||||
class CentroidLabels extends PureComponent {
|
||||
// Check to see if centroids have either just been displayed or removed from the overlay
|
||||
componentDidUpdate = prevProps => {
|
||||
const { labels, overlayToggled } = this.props;
|
||||
const prevSize = prevProps.labels.size;
|
||||
const { size } = labels;
|
||||
|
||||
const displayChangeOff = prevSize > 0 && size === undefined;
|
||||
const displayChangeOn = prevSize === undefined && size > 0;
|
||||
|
||||
if (displayChangeOn || displayChangeOff) {
|
||||
// Notify overlay layer of display change
|
||||
overlayToggled("centroidLabels", displayChangeOn);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
labels,
|
||||
inverseTransform,
|
||||
dilatedValue,
|
||||
dispatch,
|
||||
colorAccessor
|
||||
} = this.props;
|
||||
|
||||
const labelSVGS = [];
|
||||
let fontSize = "15px";
|
||||
let fontWeight = null;
|
||||
labels.forEach((value, key) => {
|
||||
fontSize = "15px";
|
||||
fontWeight = null;
|
||||
if (key === dilatedValue) {
|
||||
fontSize = "18px";
|
||||
fontWeight = "800";
|
||||
}
|
||||
|
||||
// Mirror LSB middle truncation
|
||||
let label = key;
|
||||
if (label.length > categoryLabelDisplayStringLongLength) {
|
||||
label = `${key.slice(
|
||||
0,
|
||||
categoryLabelDisplayStringLongLength / 2
|
||||
)}…${key.slice(-categoryLabelDisplayStringLongLength / 2)}`;
|
||||
}
|
||||
|
||||
labelSVGS.push(
|
||||
<g
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={key}
|
||||
className="centroid-label"
|
||||
transform={`translate(${value[0]}, ${value[1]})`}
|
||||
data-testclass="centroid-label"
|
||||
data-testid={`${key}-centroid-label`}
|
||||
>
|
||||
<text
|
||||
transform={inverseTransform}
|
||||
textAnchor="middle"
|
||||
data-label={key}
|
||||
style={{
|
||||
fontFamily: "Roboto Condensed",
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fill: "black",
|
||||
userSelect: "none"
|
||||
}}
|
||||
onMouseEnter={e =>
|
||||
dispatch({
|
||||
type: "category value mouse hover start",
|
||||
metadataField: colorAccessor,
|
||||
categoryField: e.target.getAttribute("data-label")
|
||||
})
|
||||
}
|
||||
onMouseOut={e =>
|
||||
dispatch({
|
||||
type: "category value mouse hover end",
|
||||
metadataField: colorAccessor,
|
||||
categoryField: e.target.getAttribute("data-label")
|
||||
})
|
||||
}
|
||||
pointerEvents="visiblePainted"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
return <>{labelSVGS}</>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { PureComponent, cloneElement } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import styles from "../graph.css";
|
||||
|
||||
export default
|
||||
@connect(state => ({
|
||||
responsive: state.responsive
|
||||
}))
|
||||
class GraphOverlayLayer extends PureComponent {
|
||||
/*
|
||||
This component takes its children (assumed in the data coordinate space ([0, 1] range, origin in bottom left corner))
|
||||
and transforms itself multiple times resulting in screen space ([0, screenWidth/Height] range, origin in top left corner)
|
||||
|
||||
Children are assigned in the graph component and must implement onDisplayChange()
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
display: {}
|
||||
};
|
||||
}
|
||||
|
||||
matrixToTransformString = m => {
|
||||
/*
|
||||
Translates the gl-matrix mat3 to SVG matrix transform style
|
||||
|
||||
mat3 SVG Transform Function
|
||||
a c e
|
||||
b d f / [a, b, 0, c, d, 0, e, f, 1] => matrix(a, b, c, d, e, f) / matrix(sx, 0, 0, sy, tx, ty) / matrix(m[0] m[3] m[1] m[4] m[6] m[7])
|
||||
0 0 1
|
||||
*/
|
||||
return `matrix(${m[0]} ${m[1]} ${m[3]} ${m[4]} ${m[6]} ${m[7]})`;
|
||||
};
|
||||
|
||||
reverseMatrixScaleTransformString = m => {
|
||||
return `matrix(${1 / m[0]} 0 0 ${1 / m[4]} 0 0)`;
|
||||
};
|
||||
|
||||
// This is passed to all children, should be called when an overlay's display state is toggled along with the overlay name and its new display state in boolean form
|
||||
overlayToggled = (overlay, displaying) => {
|
||||
this.setState(state => {
|
||||
return { ...state, display: { ...state.display, [overlay]: displaying } };
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
cameraTF,
|
||||
modelTF,
|
||||
projectionTF,
|
||||
responsive,
|
||||
graphPaddingRightLeft,
|
||||
graphPaddingTop,
|
||||
children,
|
||||
handleCanvasEvent
|
||||
} = this.props;
|
||||
|
||||
if (!cameraTF) return null;
|
||||
|
||||
const { display } = this.state;
|
||||
const displaying = Object.values(display).some(value => value); // check to see if at least one overlay is currently displayed
|
||||
|
||||
const inverseTransform = `${this.reverseMatrixScaleTransformString(
|
||||
modelTF
|
||||
)} ${this.reverseMatrixScaleTransformString(
|
||||
cameraTF
|
||||
)} ${this.reverseMatrixScaleTransformString(
|
||||
projectionTF
|
||||
)} scale(1 2) scale(1 ${1 /
|
||||
-(responsive.height - graphPaddingTop)}) scale(2 1) scale(${1 /
|
||||
(responsive.width - graphPaddingRightLeft)} 1)`;
|
||||
|
||||
// Copy the children passed with the overlay and add the inverse transform and onDisplayChange props
|
||||
const newChildren = React.Children.map(children, child =>
|
||||
cloneElement(child, {
|
||||
inverseTransform,
|
||||
overlayToggled: this.overlayToggled
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={styles.graphSVG}
|
||||
width={responsive.width - graphPaddingRightLeft}
|
||||
height={responsive.height}
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
zIndex: 99,
|
||||
backgroundColor: displaying ? "rgba(255, 255, 255, 0.55)" : ""
|
||||
}}
|
||||
onMouseMove={handleCanvasEvent}
|
||||
onWheel={handleCanvasEvent}
|
||||
>
|
||||
<g
|
||||
id="canvas-transformation-group-x"
|
||||
transform={`scale(${responsive.width -
|
||||
graphPaddingRightLeft} 1) scale(.5 1) translate(1 0)`}
|
||||
>
|
||||
<g
|
||||
id="canvas-transformation-group-y"
|
||||
transform={`scale(1 ${-(
|
||||
responsive.height - graphPaddingTop
|
||||
)}) translate(0 -1) scale(1 .5) translate(0 1)`}
|
||||
>
|
||||
<g
|
||||
id="projection-transformation-group"
|
||||
transform={this.matrixToTransformString(projectionTF)}
|
||||
>
|
||||
<g
|
||||
id="camera-transformation-group"
|
||||
transform={this.matrixToTransformString(cameraTF)}
|
||||
>
|
||||
<g
|
||||
id="model-transformation-group"
|
||||
transform={this.matrixToTransformString(modelTF)}
|
||||
>
|
||||
{newChildren}
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// 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("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;
|
||||
@@ -1,6 +1,6 @@
|
||||
// jshint esversion: 6
|
||||
import * as d3 from "d3";
|
||||
import styles from "./graph.css";
|
||||
import Lasso from "./setupLasso";
|
||||
|
||||
/******************************************
|
||||
*******************************************
|
||||
@@ -9,32 +9,47 @@ import styles from "./graph.css";
|
||||
******************************************/
|
||||
|
||||
export default (
|
||||
handleBrushSelectAction,
|
||||
handleBrushDeselectAction,
|
||||
selectionToolType,
|
||||
handleStartAction,
|
||||
handleDragAction,
|
||||
handleEndAction,
|
||||
handleCancelAction,
|
||||
responsive,
|
||||
graphPaddingRight
|
||||
) => {
|
||||
const svg = d3
|
||||
.select("#graphAttachPoint")
|
||||
.append("svg")
|
||||
.attr("width", responsive.width - graphPaddingRight)
|
||||
.attr("height", responsive.height)
|
||||
.attr("class", `${styles.graphSVG}`);
|
||||
const svg = d3.select("#graphAttachPoint").select("#lasso-layer");
|
||||
|
||||
const brush = d3
|
||||
.brush()
|
||||
.extent([[0, 0], [responsive.width - graphPaddingRight, responsive.height]])
|
||||
.on("brush", handleBrushSelectAction)
|
||||
.on("end", handleBrushDeselectAction);
|
||||
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);
|
||||
const brushContainer = svg
|
||||
.append("g")
|
||||
.attr("class", "graph_brush")
|
||||
.call(brush);
|
||||
|
||||
return {
|
||||
svg,
|
||||
brushContainer,
|
||||
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");
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
// createExpressionsCountsMap () {
|
||||
//
|
||||
// const CHANGE_ME_MAGIC_GENE_INDEX = 5;
|
||||
//
|
||||
// const expressionsCountsMap = {};
|
||||
//
|
||||
// /* currently selected gene */
|
||||
// expressionsCountsMap.geneName = this.state.expressions.data.genes[3];
|
||||
//
|
||||
// let maxExpressionValue = 0;
|
||||
//
|
||||
// /* create map of expressions for every cell */
|
||||
// this.state.expressions.data.cells.map((c) => {
|
||||
// /* cellname = 234 */
|
||||
// expressionsCountsMap[c.cellname] = c["e"][CHANGE_ME_MAGIC_GENE_INDEX];
|
||||
// /* collect the maximum value as we iterate */
|
||||
// if (c["e"][CHANGE_ME_MAGIC_GENE_INDEX] > maxExpressionValue) {
|
||||
// maxExpressionValue = c["e"][CHANGE_ME_MAGIC_GENE_INDEX]
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// expressionsCountsMap.maxValue = maxExpressionValue;
|
||||
//
|
||||
// return expressionsCountsMap;
|
||||
// }
|
||||
@@ -1,16 +1,13 @@
|
||||
// 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";
|
||||
import Categorical from "../categorical/categorical";
|
||||
import * as globals from "../../globals";
|
||||
import DynamicScatterplot from "../scatterplot/scatterplot";
|
||||
import TopLeftLogoAndTitle from "./topLeftLogoAndTitle";
|
||||
|
||||
@connect(state => ({
|
||||
responsive: state.responsive,
|
||||
datasetTitle: _.get(state.config, "displayNames.dataset"),
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
|
||||
}))
|
||||
@@ -18,7 +15,6 @@ class LeftSideBar extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
responsive,
|
||||
datasetTitle,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor
|
||||
} = this.props;
|
||||
@@ -27,8 +23,7 @@ class LeftSideBar extends React.Component {
|
||||
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;
|
||||
const logoRelatedPadding = 50;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -36,34 +31,20 @@ class LeftSideBar extends React.Component {
|
||||
position: "fixed",
|
||||
backgroundColor: "white",
|
||||
/* x y blur spread color */
|
||||
boxShadow: "1px 0px 6px 2px rgba(153,153,153,0.4)"
|
||||
borderRight: `1px solid ${globals.lightGrey}`
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: globals.cellxgeneTitleTopPadding,
|
||||
left: globals.leftSidebarWidth + globals.cellxgeneTitleLeftPadding,
|
||||
margin: 0,
|
||||
fontSize: globals.largestFontSize,
|
||||
color: globals.darkerGrey,
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
cellxgene: {datasetTitle}
|
||||
</p>
|
||||
|
||||
<TopLeftLogoAndTitle />
|
||||
<div
|
||||
style={{
|
||||
height: responsive.height - metadataSectionPadding,
|
||||
height: responsive.height - logoRelatedPadding,
|
||||
marginTop: logoRelatedPadding,
|
||||
width: globals.leftSidebarWidth,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden"
|
||||
}}
|
||||
>
|
||||
<Categorical />
|
||||
<GeneExpression />
|
||||
<Continuous />
|
||||
</div>
|
||||
{scatterplotXXaccessor && scatterplotYYaccessor ? (
|
||||
<DynamicScatterplot />
|
||||
@@ -0,0 +1,90 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import Logo from "../framework/logo";
|
||||
|
||||
@connect(state => ({
|
||||
responsive: state.responsive,
|
||||
datasetTitle: state.config?.displayNames?.dataset ?? "",
|
||||
aboutURL: state.config?.links?.["about-dataset"],
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
|
||||
}))
|
||||
class LeftSideBar extends React.Component {
|
||||
render() {
|
||||
const { datasetTitle, aboutURL } = this.props;
|
||||
|
||||
const paddingToAvoidScrollBar = 15;
|
||||
|
||||
const displayTitle =
|
||||
datasetTitle.length > globals.datasetTitleMaxCharacterCount
|
||||
? `${datasetTitle.substring(
|
||||
0,
|
||||
Math.floor(globals.datasetTitleMaxCharacterCount / 2)
|
||||
)}…${datasetTitle.slice(
|
||||
-Math.floor(globals.datasetTitleMaxCharacterCount / 2)
|
||||
)}`
|
||||
: datasetTitle;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 8,
|
||||
paddingTop: 8,
|
||||
width: globals.leftSidebarWidth - paddingToAvoidScrollBar,
|
||||
position: "absolute",
|
||||
backgroundColor: "white",
|
||||
zIndex: 8888
|
||||
/* x y blur spread color */
|
||||
// boxShadow: "-5px -1px 4px 2px rgba(225,225,225,0.4)"
|
||||
}}
|
||||
>
|
||||
<Logo size={30} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 28,
|
||||
position: "relative",
|
||||
top: -6,
|
||||
fontWeight: "bold",
|
||||
marginLeft: 5,
|
||||
color: globals.logoColor,
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
cell
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
top: 1,
|
||||
fontWeight: 300,
|
||||
fontSize: 24
|
||||
}}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
gene
|
||||
</span>
|
||||
<div
|
||||
data-testid="header"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
position: "relative",
|
||||
top: -6,
|
||||
display: "inline-block",
|
||||
width: "190px",
|
||||
marginLeft: "7px",
|
||||
height: "1.2em",
|
||||
overflow: "hidden",
|
||||
wordBreak: "break-all"
|
||||
}}
|
||||
title={datasetTitle}
|
||||
>
|
||||
{aboutURL ? <a href={aboutURL}>{displayTitle}</a> : displayTitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LeftSideBar;
|
||||
@@ -0,0 +1,63 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import { World } from "../../util/stateManager";
|
||||
import { tooltipHoverOpenDelay } from "../../globals";
|
||||
|
||||
@connect()
|
||||
class CellSetButton extends React.PureComponent {
|
||||
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}`;
|
||||
const cellsSelected = differential[cellListName]
|
||||
? differential[cellListName].length
|
||||
: 0;
|
||||
return (
|
||||
<Tooltip
|
||||
content="Save current selection for differential expression computation"
|
||||
position="bottom"
|
||||
hoverOpenDelay={tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
disabled={differential.diffExp}
|
||||
onClick={this.set.bind(this)}
|
||||
data-testid={`cellset-button-${eitherCellSetOneOrTwo}`}
|
||||
>
|
||||
{eitherCellSetOneOrTwo}
|
||||
{": "}
|
||||
<span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}>
|
||||
{cellsSelected}
|
||||
</span>
|
||||
{" cells"}
|
||||
</AnchorButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CellSetButton;
|
||||
@@ -0,0 +1,138 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import {
|
||||
Position,
|
||||
Button,
|
||||
Popover,
|
||||
NumericInput,
|
||||
Icon,
|
||||
Tooltip
|
||||
} from "@blueprintjs/core";
|
||||
import { tooltipHoverOpenDelay } from "../../globals";
|
||||
|
||||
function Clip(props) {
|
||||
const {
|
||||
pendingClipPercentiles,
|
||||
clipPercentileMin,
|
||||
clipPercentileMax,
|
||||
handleClipOpening,
|
||||
handleClipClosing,
|
||||
handleClipCommit,
|
||||
isClipDisabled,
|
||||
handleClipOnKeyPress,
|
||||
handleClipPercentileMaxValueChange,
|
||||
handleClipPercentileMinValueChange
|
||||
} = props;
|
||||
|
||||
const clipMin =
|
||||
pendingClipPercentiles?.clipPercentileMin ?? clipPercentileMin;
|
||||
const clipMax =
|
||||
pendingClipPercentiles?.clipPercentileMax ?? clipPercentileMax;
|
||||
const activeClipClass =
|
||||
clipPercentileMin > 0 || clipPercentileMax < 100
|
||||
? " bp3-intent-warning"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bp3-button-group"
|
||||
style={{
|
||||
marginRight: 10
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
target={
|
||||
<Tooltip
|
||||
content="Clip all continuous values to a percentile range"
|
||||
position="bottom"
|
||||
hoverOpenDelay={tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="visualization-settings"
|
||||
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
position={Position.BOTTOM_RIGHT}
|
||||
onOpening={handleClipOpening}
|
||||
onClosing={handleClipClosing}
|
||||
content={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-start",
|
||||
flexDirection: "column",
|
||||
padding: 10
|
||||
}}
|
||||
>
|
||||
<div>Clip all continuous values to percentile range</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5
|
||||
}}
|
||||
>
|
||||
<NumericInput
|
||||
style={{ width: 50 }}
|
||||
data-testid="clip-min-input"
|
||||
onValueChange={handleClipPercentileMinValueChange}
|
||||
onKeyPress={handleClipOnKeyPress}
|
||||
value={clipMin}
|
||||
min={0}
|
||||
max={100}
|
||||
fill={false}
|
||||
minorStepSize={null}
|
||||
rightElement={
|
||||
<div style={{ padding: "4px 2px" }}>
|
||||
<Icon icon="percentage" intent="primary" iconSize={14} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<span style={{ marginRight: 5, marginLeft: 5 }}> - </span>
|
||||
<NumericInput
|
||||
style={{ width: 50 }}
|
||||
data-testid="clip-max-input"
|
||||
onValueChange={handleClipPercentileMaxValueChange}
|
||||
onKeyPress={handleClipOnKeyPress}
|
||||
value={clipMax}
|
||||
min={0}
|
||||
max={100}
|
||||
fill={false}
|
||||
minorStepSize={null}
|
||||
rightElement={
|
||||
<div style={{ padding: "4px 2px" }}>
|
||||
<Icon icon="percentage" intent="primary" iconSize={14} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="clip-commit"
|
||||
intent="primary"
|
||||
disabled={isClipDisabled()}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
marginRight: 5,
|
||||
marginLeft: 5
|
||||
}}
|
||||
onClick={handleClipCommit}
|
||||
>
|
||||
Clip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Clip;
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AnchorButton,
|
||||
ButtonGroup,
|
||||
Popover,
|
||||
Button,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Tooltip,
|
||||
Position
|
||||
} from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import { World } from "../../util/stateManager";
|
||||
import actions from "../../actions";
|
||||
|
||||
@connect(state => ({
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
layoutChoice: state.layoutChoice,
|
||||
reembedController: state.reembedController,
|
||||
enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false
|
||||
}))
|
||||
class Embedding extends React.PureComponent {
|
||||
handleLayoutChoiceChange = e => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "set layout choice",
|
||||
layoutChoice: e.currentTarget.value
|
||||
});
|
||||
};
|
||||
|
||||
renderReembedding() {
|
||||
const {
|
||||
enableReembedding,
|
||||
world,
|
||||
universe,
|
||||
dispatch,
|
||||
reembedController
|
||||
} = this.props;
|
||||
|
||||
if (!enableReembedding) return null;
|
||||
|
||||
const loading = !!reembedController?.pendingFetch;
|
||||
const disabled = World.worldEqUniverse(world, universe);
|
||||
const tipContent = disabled
|
||||
? "Subset cells first, then click to recompute UMAP embedding."
|
||||
: "Click to recompute UMAP embedding on the current cell subset.";
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={tipContent}
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
icon="new-object"
|
||||
style={{ marginRight: 10 }}
|
||||
disabled={disabled}
|
||||
onClick={() => dispatch(actions.requestReembed())}
|
||||
loading={loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { layoutChoice } = this.props;
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
style={{
|
||||
marginRight: 10
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
target={
|
||||
<Tooltip
|
||||
content="Select embedding for visualization"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="layout-choice"
|
||||
icon="heatmap"
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
position={Position.BOTTOM_RIGHT}
|
||||
content={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-start",
|
||||
flexDirection: "column",
|
||||
padding: 10
|
||||
}}
|
||||
>
|
||||
<RadioGroup
|
||||
label="Embedding Choice"
|
||||
onChange={this.handleLayoutChoiceChange}
|
||||
selectedValue={layoutChoice.current}
|
||||
>
|
||||
{layoutChoice.available.map(name => (
|
||||
<Radio label={name} value={name} key={name} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{this.renderReembedding()}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Embedding;
|
||||
@@ -0,0 +1,411 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Button, ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import CellSetButton from "./cellSetButtons";
|
||||
import Clip from "./clip";
|
||||
import Embedding from "./embedding";
|
||||
import InformationMenu from "./infoMenu";
|
||||
import Subset from "./subset";
|
||||
import UndoRedoReset from "./undoRedo";
|
||||
|
||||
@connect(state => ({
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter,
|
||||
differential: state.differential,
|
||||
graphInteractionMode: state.controls.graphInteractionMode,
|
||||
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
|
||||
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
diffexpGenes: state.controls.diffexpGenes,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
celllist1: state.differential.celllist1,
|
||||
celllist2: state.differential.celllist2,
|
||||
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase
|
||||
undoDisabled: state["@@undoable/past"].length === 0,
|
||||
redoDisabled: state["@@undoable/future"].length === 0,
|
||||
aboutLink: state.config?.links?.["about-dataset"],
|
||||
disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false,
|
||||
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
|
||||
showCentroidLabels: state.centroidLabels.showLabels
|
||||
}))
|
||||
class MenuBar extends React.Component {
|
||||
static isValidDigitKeyEvent(e) {
|
||||
/*
|
||||
Return true if this event is necessary to enter a percent number input.
|
||||
Return false if not.
|
||||
|
||||
Returns true for events with keys: backspace, control, alt, meta, [0-9],
|
||||
or events that don't have a key.
|
||||
*/
|
||||
if (e.key === null) return true;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return true;
|
||||
|
||||
// concept borrowed from blueprint's numericInputUtils:
|
||||
// keys that print a single character when pressed have a `key` name of
|
||||
// length 1. every other key has a longer `key` name (e.g. "Backspace",
|
||||
// "ArrowUp", "Shift"). since none of those keys can print a character
|
||||
// to the field--and since they may have important native behaviors
|
||||
// beyond printing a character--we don't want to disable their effects.
|
||||
const isSingleCharKey = e.key.length === 1;
|
||||
if (!isSingleCharKey) return true;
|
||||
|
||||
const key = e.key.charCodeAt(0) - 48; /* "0" */
|
||||
return key >= 0 && key <= 9;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
pendingClipPercentiles: null
|
||||
};
|
||||
}
|
||||
|
||||
isClipDisabled = () => {
|
||||
/*
|
||||
return true if clip button should be disabled.
|
||||
*/
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
|
||||
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
|
||||
|
||||
const { world } = this.props;
|
||||
const currentClipMin = 100 * world?.clipQuantiles?.min;
|
||||
const currentClipMax = 100 * world?.clipQuantiles?.max;
|
||||
|
||||
// if you change this test, be careful with logic around
|
||||
// comparisons between undefined / NaN handling.
|
||||
const isDisabled =
|
||||
!(clipPercentileMin < clipPercentileMax) ||
|
||||
(clipPercentileMin === currentClipMin &&
|
||||
clipPercentileMax === currentClipMax);
|
||||
|
||||
return isDisabled;
|
||||
};
|
||||
|
||||
handleClipOnKeyPress = e => {
|
||||
/*
|
||||
allow only numbers, plus other critical keys which
|
||||
may be required to make a number
|
||||
*/
|
||||
if (!MenuBar.isValidDigitKeyEvent(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
handleClipPercentileMinValueChange = v => {
|
||||
/*
|
||||
Ignore anything that isn't a legit number
|
||||
*/
|
||||
if (!Number.isFinite(v)) return;
|
||||
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
|
||||
|
||||
/*
|
||||
clamp to [0, currentClipPercentileMax]
|
||||
*/
|
||||
if (v <= 0) v = 0;
|
||||
if (v > 100) v = 100;
|
||||
const clipPercentileMin = Math.round(v); // paranoia
|
||||
this.setState({
|
||||
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
|
||||
});
|
||||
};
|
||||
|
||||
handleClipPercentileMaxValueChange = v => {
|
||||
/*
|
||||
Ignore anything that isn't a legit number
|
||||
*/
|
||||
if (!Number.isFinite(v)) return;
|
||||
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
|
||||
|
||||
/*
|
||||
clamp to [0, 100]
|
||||
*/
|
||||
if (v < 0) v = 0;
|
||||
if (v > 100) v = 100;
|
||||
const clipPercentileMax = Math.round(v); // paranoia
|
||||
|
||||
this.setState({
|
||||
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
|
||||
});
|
||||
};
|
||||
|
||||
handleClipCommit = () => {
|
||||
const { dispatch } = this.props;
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
|
||||
const min = clipPercentileMin / 100;
|
||||
const max = clipPercentileMax / 100;
|
||||
dispatch({
|
||||
type: "set clip quantiles",
|
||||
clipQuantiles: { min, max }
|
||||
});
|
||||
};
|
||||
|
||||
handleClipOpening = () => {
|
||||
const { clipPercentileMin, clipPercentileMax } = this.props;
|
||||
this.setState({
|
||||
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
|
||||
});
|
||||
};
|
||||
|
||||
handleClipClosing = () => {
|
||||
this.setState({ pendingClipPercentiles: null });
|
||||
};
|
||||
|
||||
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"
|
||||
});
|
||||
};
|
||||
|
||||
handleCentroidChange = () => {
|
||||
const { dispatch, showCentroidLabels } = this.props;
|
||||
|
||||
dispatch({
|
||||
type: "show centroid labels for category",
|
||||
showLabels: !showCentroidLabels
|
||||
});
|
||||
};
|
||||
|
||||
subsetPossible = () => {
|
||||
const { crossfilter } = this.props;
|
||||
return (
|
||||
crossfilter.countSelected() !== 0 &&
|
||||
crossfilter.countSelected() !== crossfilter.size()
|
||||
);
|
||||
};
|
||||
|
||||
subsetResetPossible = () => {
|
||||
const { world, universe } = this.props;
|
||||
return world.nObs !== universe.nObs;
|
||||
};
|
||||
|
||||
renderDiffExp() {
|
||||
/* diffexp-related buttons may be disabled */
|
||||
const { disableDiffexp, differential, diffexpMayBeSlow } = this.props;
|
||||
if (disableDiffexp) return null;
|
||||
|
||||
const haveBothCellSets =
|
||||
!!differential.celllist1 && !!differential.celllist2;
|
||||
|
||||
const slowMsg = diffexpMayBeSlow
|
||||
? " (CAUTION: large dataset - may take longer or fail)"
|
||||
: "";
|
||||
const tipMessage = `See top 10 differentially expressed genes${slowMsg}`;
|
||||
|
||||
return (
|
||||
<ButtonGroup style={{ marginRight: 10 }}>
|
||||
<CellSetButton
|
||||
{...this.props} // eslint-disable-line react/jsx-props-no-spreading
|
||||
eitherCellSetOneOrTwo={1}
|
||||
/>
|
||||
<CellSetButton
|
||||
{...this.props} // eslint-disable-line react/jsx-props-no-spreading
|
||||
eitherCellSetOneOrTwo={2}
|
||||
/>
|
||||
{!differential.diffExp ? (
|
||||
<Tooltip
|
||||
content={tipMessage}
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelayQuick}
|
||||
>
|
||||
<AnchorButton
|
||||
disabled={!haveBothCellSets}
|
||||
intent="primary"
|
||||
data-testid="diffexp-button"
|
||||
loading={differential.loading}
|
||||
icon="left-join"
|
||||
fill
|
||||
onClick={this.computeDiffExp}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{differential.diffExp ? (
|
||||
<Tooltip
|
||||
content="Remove differentially expressed gene list and clear cell selections"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelayQuick}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
fill
|
||||
intent="warning"
|
||||
onClick={this.clearDifferentialExpression}
|
||||
>
|
||||
Clear Differential Expression
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatch,
|
||||
libraryVersions,
|
||||
undoDisabled,
|
||||
redoDisabled,
|
||||
selectionTool,
|
||||
clipPercentileMin,
|
||||
clipPercentileMax,
|
||||
graphInteractionMode,
|
||||
aboutLink,
|
||||
showCentroidLabels
|
||||
} = this.props;
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
|
||||
// constants used to create selection tool button
|
||||
let selectionTooltip;
|
||||
let selectionButtonIcon;
|
||||
if (selectionTool === "brush") {
|
||||
selectionTooltip = "Brush selection";
|
||||
selectionButtonIcon = "select";
|
||||
} else {
|
||||
selectionTooltip = "Lasso selection";
|
||||
selectionButtonIcon = "polygon-filter";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: globals.leftSidebarWidth + 8,
|
||||
top: 8,
|
||||
display: "flex"
|
||||
}}
|
||||
>
|
||||
{this.renderDiffExp()}
|
||||
<Subset
|
||||
subsetPossible={this.subsetPossible()}
|
||||
subsetResetPossible={this.subsetResetPossible()}
|
||||
handleSubset={() => {
|
||||
dispatch(actions.setWorldToSelection());
|
||||
dispatch({ type: "increment graph render counter" });
|
||||
}}
|
||||
handleSubsetReset={() => {
|
||||
dispatch(actions.resetWorldToUniverse());
|
||||
dispatch({ type: "increment graph render counter" });
|
||||
}}
|
||||
/>
|
||||
<ButtonGroup style={{ marginRight: "10px" }}>
|
||||
<Tooltip
|
||||
content={selectionTooltip}
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
data-testid="mode-lasso"
|
||||
icon={selectionButtonIcon}
|
||||
active={graphInteractionMode === "select"}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "change graph interaction mode",
|
||||
data: "select"
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content="Drag to pan, scroll to zoom"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
data-testid="mode-pan-zoom"
|
||||
icon="zoom-in"
|
||||
active={graphInteractionMode === "zoom"}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "change graph interaction mode",
|
||||
data: "zoom"
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
<Tooltip
|
||||
content="When a category is colored by, show labels on the graph"
|
||||
position="bottom"
|
||||
disabled={graphInteractionMode === "zoom"}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="centroid-label-toggle"
|
||||
icon="property"
|
||||
onClick={this.handleCentroidChange}
|
||||
active={showCentroidLabels}
|
||||
intent={showCentroidLabels ? "primary" : "none"}
|
||||
style={{
|
||||
marginRight: 10
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Embedding />
|
||||
<Clip
|
||||
pendingClipPercentiles={pendingClipPercentiles}
|
||||
clipPercentileMin={clipPercentileMin}
|
||||
clipPercentileMax={clipPercentileMax}
|
||||
handleClipOpening={this.handleClipOpening}
|
||||
handleClipClosing={this.handleClipClosing}
|
||||
handleClipCommit={this.handleClipCommit}
|
||||
isClipDisabled={this.isClipDisabled}
|
||||
handleClipOnKeyPress={this.handleClipOnKeyPress}
|
||||
handleClipPercentileMaxValueChange={
|
||||
this.handleClipPercentileMaxValueChange
|
||||
}
|
||||
handleClipPercentileMinValueChange={
|
||||
this.handleClipPercentileMinValueChange
|
||||
}
|
||||
/>
|
||||
<UndoRedoReset
|
||||
dispatch={dispatch}
|
||||
undoDisabled={undoDisabled}
|
||||
redoDisabled={redoDisabled}
|
||||
/>
|
||||
<InformationMenu
|
||||
libraryVersions={libraryVersions}
|
||||
aboutLink={aboutLink}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MenuBar;
|
||||
@@ -0,0 +1,66 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
|
||||
|
||||
function InformationMenu(props) {
|
||||
const { libraryVersions, aboutLink } = props;
|
||||
return (
|
||||
<div style={{}} className="bp3-button-group">
|
||||
<Popover
|
||||
content={
|
||||
<Menu>
|
||||
{aboutLink ? (
|
||||
<MenuItem
|
||||
href={aboutLink}
|
||||
target="_blank"
|
||||
icon="document-open"
|
||||
text="About this dataset"
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
href="https://chanzuckerberg.github.io/cellxgene/"
|
||||
target="_blank"
|
||||
icon="help"
|
||||
text="Help"
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://join-cellxgene-users.herokuapp.com/"
|
||||
target="_blank"
|
||||
icon="chat"
|
||||
text="Chat"
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://github.com/chanzuckerberg/cellxgene"
|
||||
target="_blank"
|
||||
icon="git-branch"
|
||||
text="Github"
|
||||
/>
|
||||
<MenuItem
|
||||
target="_blank"
|
||||
text={`cellxgene v${
|
||||
libraryVersions && libraryVersions.cellxgene
|
||||
? libraryVersions.cellxgene
|
||||
: null
|
||||
}`}
|
||||
/>
|
||||
<MenuItem text="MIT License" />
|
||||
</Menu>
|
||||
}
|
||||
position={Position.BOTTOM_RIGHT}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-info-sign"
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InformationMenu;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { AnchorButton, ButtonGroup, Tooltip } from "@blueprintjs/core";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
function Subset(props) {
|
||||
const {
|
||||
subsetPossible,
|
||||
subsetResetPossible,
|
||||
handleSubset,
|
||||
handleSubsetReset
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ButtonGroup style={{ marginRight: "10px" }}>
|
||||
<Tooltip
|
||||
content="Subset to currently selected cells and associated metadata"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
data-testid="subset-button"
|
||||
disabled={!subsetPossible}
|
||||
icon="pie-chart"
|
||||
onClick={handleSubset}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content="Undo subset and show all cells and associated metadata"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
data-testid="reset-subset-button"
|
||||
disabled={!subsetResetPossible}
|
||||
icon="full-circle"
|
||||
onClick={handleSubsetReset}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default Subset;
|
||||
@@ -0,0 +1,54 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
import { tooltipHoverOpenDelay } from "../../globals";
|
||||
|
||||
function InformationMenu(props) {
|
||||
const {
|
||||
undoDisabled,
|
||||
redoDisabled,
|
||||
dispatch
|
||||
} = props;
|
||||
return (
|
||||
<div style={{ marginRight: 10 }} className="bp3-button-group">
|
||||
<Tooltip
|
||||
content="Undo"
|
||||
position="bottom"
|
||||
hoverOpenDelay={tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-undo"
|
||||
disabled={undoDisabled}
|
||||
onClick={() => {
|
||||
dispatch({ type: "@@undoable/undo" });
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
data-testid="undo"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content="Redo"
|
||||
position="bottom"
|
||||
hoverOpenDelay={tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-redo"
|
||||
disabled={redoDisabled}
|
||||
onClick={() => {
|
||||
dispatch({ type: "@@undoable/redo" });
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
data-testid="redo"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InformationMenu;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user