mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 08:08:13 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
523048de43 | ||
|
|
07cda497d9 | ||
|
|
00a68276a2 | ||
|
|
bc0cecbd1c | ||
|
|
dbff824854 | ||
|
|
9decf134e0 | ||
|
|
47ce0cfc49 | ||
|
|
018e3ae5c4 | ||
|
|
aa29c91470 | ||
|
|
dd56d0937c | ||
|
|
dfcef6075e | ||
|
|
b4edd56735 | ||
|
|
1dc80145dc | ||
|
|
6916aec6c3 | ||
|
|
9f7e1f1cb0 | ||
|
|
121360c0a5 | ||
|
|
7d5c054b90 | ||
|
|
a9189017c7 | ||
|
|
2d5bc9d0c9 | ||
|
|
24a8a23355 | ||
|
|
0dcc17d6d5 | ||
|
|
e2d864a336 | ||
|
|
0c8a07ac13 | ||
|
|
6e46a65c32 | ||
|
|
9d7754e31e | ||
|
|
b181751493 | ||
|
|
01369801fc | ||
|
|
bc15e9229f | ||
|
|
4f888505ee | ||
|
|
26d222cd75 | ||
|
|
75ca14e4aa | ||
|
|
ed66ae6e4a | ||
|
|
6c23a72e5f | ||
|
|
16b0741a6c | ||
|
|
d221d76be6 | ||
|
|
94f95d6565 | ||
|
|
02b2349807 | ||
|
|
9e5114a730 | ||
|
|
85f44ccabf | ||
|
|
7aa390b5a1 | ||
|
|
3556bd11f5 | ||
|
|
4ec938c166 | ||
|
|
b74c9e15c6 | ||
|
|
96675f806c | ||
|
|
df671192be | ||
|
|
0c271bc815 | ||
|
|
efb55a6332 | ||
|
|
2a3577f32b | ||
|
|
5dea303bcd | ||
|
|
06f402ab0a | ||
|
|
2616b5c185 | ||
|
|
f33a3cd1a3 | ||
|
|
7c8b89eba3 | ||
|
|
f9f43c42c0 | ||
|
|
952578680a | ||
|
|
76ec29734a | ||
|
|
6b0aafd27c | ||
|
|
65f5244ff7 | ||
|
|
e7642e6a8f | ||
|
|
8114d7565a | ||
|
|
8e08534d42 | ||
|
|
2b77f8146b | ||
|
|
e1a437514e | ||
|
|
48fa221ad9 | ||
|
|
fff331e1f0 | ||
|
|
8d07b400be | ||
|
|
5b2c07f105 | ||
|
|
ca35ae0b40 | ||
|
|
1cf9fc76b0 | ||
|
|
a42f9f83f5 | ||
|
|
9de44e6fd4 | ||
|
|
ca37483447 | ||
|
|
dd1b9229fc | ||
|
|
73f55cb9f8 | ||
|
|
8aa75458ed | ||
|
|
0c8f4ed824 | ||
|
|
5165297e2a | ||
|
|
9c8e0e76ad | ||
|
|
693dc7be12 | ||
|
|
090bad040e | ||
|
|
a297956e69 | ||
|
|
b0603dbed7 | ||
|
|
30069c0e66 | ||
|
|
2c74291ee9 | ||
|
|
329cf64a49 | ||
|
|
c7e6f54450 | ||
|
|
bf0154ada6 | ||
|
|
dcb3f6e901 | ||
|
|
2e9d576890 | ||
|
|
3229babd14 | ||
|
|
b90292ce4c | ||
|
|
210787cb5d | ||
|
|
ee0b066eb6 | ||
|
|
ab76c57fdb | ||
|
|
b77d7b14b5 | ||
|
|
aea934bb6d | ||
|
|
eb3b3fc59a | ||
|
|
dce778f658 | ||
|
|
11ef5ae51b | ||
|
|
8be0833d23 | ||
|
|
a0aae2e266 | ||
|
|
19f2728a18 | ||
|
|
8674ad1f7f | ||
|
|
187bbfdcf7 | ||
|
|
467ae94faf | ||
|
|
d09c820c1b | ||
|
|
67fc20b69a | ||
|
|
eeec842ad0 | ||
|
|
f0d9d873be | ||
|
|
2d218fa099 | ||
|
|
023fd01957 | ||
|
|
7a39642593 | ||
|
|
967ea21d8a | ||
|
|
4543b002e3 | ||
|
|
d9a75fcd10 | ||
|
|
9ee8781f32 | ||
|
|
b2c42b6379 | ||
|
|
f8b00af011 | ||
|
|
d4e850d18f | ||
|
|
7b00fea44d | ||
|
|
18e1b0f2fb | ||
|
|
f7abee5156 | ||
|
|
fddf114076 | ||
|
|
d550b5503d | ||
|
|
743eee3d78 | ||
|
|
d492e129d0 | ||
|
|
6665f55075 | ||
|
|
44ef1af863 | ||
|
|
6fd7f23364 | ||
|
|
3c9de7be9e | ||
|
|
3087c173e6 | ||
|
|
e4cd873b21 | ||
|
|
67bf7491c6 | ||
|
|
531e60caa4 | ||
|
|
f8bf32656c | ||
|
|
a7f728a9e8 | ||
|
|
1712114c9d | ||
|
|
39414503bd | ||
|
|
451c930cb5 | ||
|
|
b2619354c5 | ||
|
|
fbf798f4c4 | ||
|
|
de5a6b1d29 | ||
|
|
831ef283a0 | ||
|
|
113cf17183 | ||
|
|
a03f4bcdd9 | ||
|
|
34561a3b08 | ||
|
|
4e80ed6c5d | ||
|
|
73b6658d29 | ||
|
|
2cda3fb68f | ||
|
|
2bf8b273d3 | ||
|
|
563b1c34c9 | ||
|
|
985cc0de94 | ||
|
|
c57d648f15 | ||
|
|
33ce77b669 | ||
|
|
9d680cf8d8 | ||
|
|
b884487789 | ||
|
|
ddaa7016a5 | ||
|
|
a29ba8da4e | ||
|
|
463cdef08b | ||
|
|
c8c098282d | ||
|
|
8c88f752ea | ||
|
|
a4464d107f | ||
|
|
d44d267bda | ||
|
|
55fa9b892a | ||
|
|
5917dd1fb4 | ||
|
|
21a1f00a64 | ||
|
|
e96653b826 | ||
|
|
d01792f22b | ||
|
|
58000c1815 | ||
|
|
4417e8f3da | ||
|
|
3961bdc46d | ||
|
|
004303300b | ||
|
|
ba1e26138f | ||
|
|
d093dc2e3b | ||
|
|
3f0bb2ce1d | ||
|
|
585d51e9cf | ||
|
|
246f4e41f0 | ||
|
|
67504dcaff | ||
|
|
af85792cce | ||
|
|
f4780a9806 | ||
|
|
8119962c28 | ||
|
|
b2add5f2ce | ||
|
|
2a11af2550 | ||
|
|
2b698cc7d0 | ||
|
|
e5c7c0c44a | ||
|
|
11f88a9eb2 | ||
|
|
bf19bb7dce | ||
|
|
c2b3f331cf | ||
|
|
499b9551f1 | ||
|
|
0969854e9e | ||
|
|
247b89ab0c | ||
|
|
736ad2ca20 | ||
|
|
77f6e918b4 | ||
|
|
ccee3e0a38 | ||
|
|
7ac8d31a96 | ||
|
|
c11ae77de4 | ||
|
|
c47f8d6ca5 | ||
|
|
10cc801197 | ||
|
|
0db24a6c07 | ||
|
|
9236f02637 | ||
|
|
957c1a9f20 | ||
|
|
12c52a55f7 | ||
|
|
16d7fac88e | ||
|
|
a2b699b05d | ||
|
|
12b8232327 | ||
|
|
a22a4e2473 | ||
|
|
00be4b710e | ||
|
|
8401d1dd3d | ||
|
|
3822d4b005 | ||
|
|
72afb3510c | ||
|
|
6bc94a53ce | ||
|
|
15923d4523 | ||
|
|
c69a730e12 | ||
|
|
ff07bb1ebb | ||
|
|
60238f61c4 | ||
|
|
8f56f0ce6e | ||
|
|
f839653c00 | ||
|
|
a39e0c79a6 | ||
|
|
0c8b86d1c4 | ||
|
|
dfea46eff0 | ||
|
|
76d5252506 | ||
|
|
cb6b784dfd | ||
|
|
e682028b8e | ||
|
|
77ca5c267e | ||
|
|
8dbb131afd | ||
|
|
64bfb7416e | ||
|
|
7ae1db627c | ||
|
|
ee5fe8f37b | ||
|
|
e11b905836 | ||
|
|
116cef551c | ||
|
|
993057b79c | ||
|
|
5400df25ca | ||
|
|
489a2dd5db | ||
|
|
0a36fac142 | ||
|
|
69fca7ea8b | ||
|
|
7642251d44 | ||
|
|
20331313e0 | ||
|
|
ee2b7fc13f | ||
|
|
78077ad566 | ||
|
|
0b84e9e804 | ||
|
|
b574542a60 | ||
|
|
516ecc2ad3 | ||
|
|
d77accc858 | ||
|
|
83e7685f57 | ||
|
|
de5e0f4ea7 | ||
|
|
4b2f24cb40 | ||
|
|
fec055ede3 | ||
|
|
fdd9b1d0bd | ||
|
|
9c72f2c2d5 | ||
|
|
2c7a11ab80 | ||
|
|
45f61d87d7 | ||
|
|
71d1cd250c | ||
|
|
558e8f522a | ||
|
|
895cfcebe0 | ||
|
|
cd7c06c822 | ||
|
|
8b0d7d5cf6 | ||
|
|
28d341cf1c | ||
|
|
59022d7a9c | ||
|
|
fdaaaf69a2 | ||
|
|
6961b64d87 | ||
|
|
a5423a6911 | ||
|
|
05b9a6833d | ||
|
|
b8f501a46c | ||
|
|
b6f253e843 | ||
|
|
851ddde130 | ||
|
|
2e1da27a31 | ||
|
|
d375c48b6a | ||
|
|
82c4d2948c | ||
|
|
5a15f5a915 | ||
|
|
8aafd7d271 | ||
|
|
02799cea8e | ||
|
|
ba9e5f1e41 | ||
|
|
cc354d7d61 | ||
|
|
03ec942723 | ||
|
|
dd019d83e5 | ||
|
|
2721e91788 | ||
|
|
2970189397 | ||
|
|
76446ca7d4 | ||
|
|
f466ef6553 | ||
|
|
a3bf802aec | ||
|
|
071c24d0f4 | ||
|
|
d937485034 | ||
|
|
0639759a90 | ||
|
|
558950852e | ||
|
|
cd1b67fd09 | ||
|
|
5b9ddfa1a6 | ||
|
|
354eacc3c8 | ||
|
|
cf12acd47c | ||
|
|
6652c07495 | ||
|
|
e3bf06ca61 | ||
|
|
01de23c533 | ||
|
|
4365b24212 | ||
|
|
aa6f38e0a3 | ||
|
|
451f95b0a4 | ||
|
|
013c5fb078 | ||
|
|
d1895ecb05 | ||
|
|
85506db971 | ||
|
|
e383e52320 | ||
|
|
2da62720dd | ||
|
|
56c932a443 | ||
|
|
40f5b94075 | ||
|
|
1eeaca41f9 | ||
|
|
7a9f0ef29e | ||
|
|
91902aa33e | ||
|
|
b24b71692c | ||
|
|
64223ff95e | ||
|
|
ba13e32206 | ||
|
|
a60b3ea296 | ||
|
|
dd5fa57259 | ||
|
|
59dcfe2bc4 | ||
|
|
c7f9dc0bb9 | ||
|
|
67f2e19f39 | ||
|
|
ca16447115 | ||
|
|
15ce4cc338 | ||
|
|
ee8bb510ff | ||
|
|
9b4bcb7f7b | ||
|
|
1621d20bc4 | ||
|
|
f96a03e58c | ||
|
|
f0e008eb66 | ||
|
|
a4e75fceba | ||
|
|
fabd3ca2ff | ||
|
|
2fb086b5ad | ||
|
|
d1d3353130 | ||
|
|
e6c912f6ad | ||
|
|
9cf300caeb | ||
|
|
7ecd61ce0f | ||
|
|
a64484a1dd | ||
|
|
ebb1bb93cf | ||
|
|
e84793ede9 | ||
|
|
c60904a145 | ||
|
|
a0d5f1bf86 | ||
|
|
9e8d48d630 | ||
|
|
0b50f02d55 | ||
|
|
a0c6e3102b | ||
|
|
37df1d7a67 | ||
|
|
2b70d165be | ||
|
|
bf6ebd5741 | ||
|
|
9c2870e995 | ||
|
|
1565f47f6e | ||
|
|
92dfc5f2ee | ||
|
|
eaa4e6c7a4 | ||
|
|
67e65e7535 | ||
|
|
20756764f9 | ||
|
|
2f23e8675f | ||
|
|
089d04ea9f | ||
|
|
cfd13cad41 | ||
|
|
2c79e98809 | ||
|
|
7874e6a68b | ||
|
|
007e1d3a66 | ||
|
|
cc661f31e1 |
@@ -0,0 +1,19 @@
|
||||
[bumpversion]
|
||||
current_version = 0.13.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,5 @@
|
||||
bin
|
||||
client
|
||||
dist
|
||||
docs
|
||||
server
|
||||
+31
-3
@@ -1,16 +1,44 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
node_modules/
|
||||
|
||||
# coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
.pytest_cache/
|
||||
|
||||
# production
|
||||
build
|
||||
build/
|
||||
dist/
|
||||
*.egg-info
|
||||
|
||||
# Environments
|
||||
venv/
|
||||
cellxgene/
|
||||
|
||||
# client build
|
||||
server/app/web/static/css/
|
||||
server/app/web/static/img/
|
||||
server/app/web/static/media/
|
||||
server/app/web/static/js/
|
||||
server/app/web/templates/index\.html
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
*.ipynb
|
||||
|
||||
# cefpython
|
||||
error.log
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
npm-debug.log
|
||||
.vscode
|
||||
|
||||
*.idea*
|
||||
__pycache__
|
||||
*.DS_Store*
|
||||
data
|
||||
|
||||
# Jekyll
|
||||
docs/_site/
|
||||
docs/Gemfile.lock
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
language: python
|
||||
dist: xenial
|
||||
sudo: required
|
||||
node_js:
|
||||
- 8
|
||||
cache:
|
||||
- pip
|
||||
- npm
|
||||
install:
|
||||
- set -eo pipefail
|
||||
- pip install flake8
|
||||
- make pydist
|
||||
- make install-dist
|
||||
- pip install -r server/requirements-dev.txt
|
||||
|
||||
jobs:
|
||||
include:
|
||||
- name: "Branch Tests 3.7"
|
||||
python: "3.7"
|
||||
script: ./travis-build.sh
|
||||
- name: "Branch Tests 3.6"
|
||||
python: "3.6"
|
||||
script: ./travis-build.sh
|
||||
- name: "Docker Build"
|
||||
install: skip
|
||||
python: "3.6"
|
||||
script: docker build .
|
||||
- name: "Smoke Tests"
|
||||
python: "3.6"
|
||||
script:
|
||||
- npm run --prefix client/ smoke-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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
recursive-include server/app/web/templates *
|
||||
recursive-include server/app/web/static *
|
||||
|
||||
include server/requirements.txt
|
||||
include server/requirements-prepare.txt
|
||||
@@ -1,9 +1,86 @@
|
||||
# cellxgene
|
||||
<img src="./docs/cellxgene-logo.png" width="300">
|
||||
|
||||
A React + Redux web application for exploring large scale single cell RNA sequence data.
|
||||
_an interactive explorer for single-cell transcriptomics data_
|
||||
|
||||
##### Quickstart:
|
||||
[](https://zenodo.org/badge/latestdoi/105615409) [](https://pypi.org/project/cellxgene/) [](https://pypistats.org/packages/cellxgene) [](https://github.com/chanzuckerberg/cellxgene/pulse)
|
||||
|
||||
* `npm install`
|
||||
* `npm start`
|
||||
* `localhost:3000`
|
||||
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.
|
||||
|
||||
Whether you need to visualize one thousand cells or one million, cellxgene helps you gain insight into your single-cell data.
|
||||
|
||||
<img src="https://github.com/chanzuckerberg/cellxgene/raw/master/docs/images/crossfilter.gif" width="350" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/raw/master/docs/images/category-breakdown.gif" width="350" height="200" hspace="30">
|
||||
|
||||
# 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
|
||||
|
||||
### Quick start
|
||||
|
||||
To install cellxgene you need Python 3.6+. We recommend [installing cellxgene into a conda or virtual environment.](https://chanzuckerberg.github.io/cellxgene/posts/install)
|
||||
|
||||
Install the package.
|
||||
``` bash
|
||||
pip install cellxgene
|
||||
```
|
||||
|
||||
Launch cellxgene with an example [anndata](https://anndata.readthedocs.io/en/latest/) file
|
||||
|
||||
``` bash
|
||||
cellxgene launch https://cellxgene-example-data.czi.technology/pbmc3k.h5ad
|
||||
```
|
||||
|
||||
To explore more datasets already formatted for cellxgene, check out the [Demo data](https://chanzuckerberg.github.io/cellxgene/posts/demo-data) or
|
||||
see [Preparing your data](https://chanzuckerberg.github.io/cellxgene/posts/prepare) to learn more about formatting your own
|
||||
data for cellxgene.
|
||||
|
||||
### Finding help
|
||||
|
||||
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!".
|
||||
|
||||
For any errors, [report bugs on Github](https://github.com/chanzuckerberg/cellxgene/issues).
|
||||
|
||||
# Developing with cellxgene
|
||||
|
||||
### 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.
|
||||
|
||||
This project adheres to the Contributor Covenant [code of conduct](https://github.com/chanzuckerberg/.github/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to opensource@chanzuckerberg.com.
|
||||
|
||||
### Reuse
|
||||
|
||||
This project was started with the sole goal of empowering the scientific community to explore and understand their data. As such, we encourage other scientific tool builders in academia or industry to adopt the patterns, tools, and code from this project, and reach out to us with ideas or questions. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
### Security
|
||||
|
||||
If you believe you have found a security issue, we would appreciate notification. Please send email to <security@chanzuckerberg.com>.
|
||||
|
||||
# About
|
||||
### Core team
|
||||
|
||||
The current core team:
|
||||
|
||||
- Colin Megill, frontend & product design
|
||||
- Bruce Martin, software engineer
|
||||
- Sidney Bell, computational biologist
|
||||
- Lia Prins, designer
|
||||
- Severiano Badajoz, software engineer
|
||||
|
||||
We would also like to gratefully acknowledge contributions from past core team members:
|
||||
|
||||
- Charlotte Weaver, software engineer
|
||||
|
||||
### Inspiration
|
||||
|
||||
We've been heavily inspired by several other related single-cell visualization projects, including the [UCSC Cell Browswer](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [Gene Pattern](http://genepattern-notebook.org/), and many others. We hope to explore collaborations where useful as this community works together on improving interactive visualization for single-cell data.
|
||||
|
||||
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
|
||||
|
||||
We have been working closely with the [scanpy](https://github.com/theislab/scanpy) team to integrate with their awesome analysis tools. Special thanks to Alex Wolf, Fabian Theis, and the rest of the team for their help during development and for providing an example dataset.
|
||||
|
||||
We are eager to explore integrations with other computational backends such as [Seurat](https://github.com/satijalab/seurat) or [Bioconductor](https://github.com/Bioconductor)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
block_cipher = None
|
||||
|
||||
|
||||
a = Analysis(['server/gui/main.py'],
|
||||
pathex=['/Users/charlotteweaver/Documents/Git/cellxgene'],
|
||||
binaries=[('/System/Library/Frameworks/Tk.framework/Tk', 'tk'), ('/System/Library/Frameworks/Tcl.framework/Tcl', 'tcl')],
|
||||
datas=[('server/app/web/templates/', 'server/app/web/templates/'), ('server/app/web/static/', 'server/app/web/static/')],
|
||||
hiddenimports=['sklearn', 'sklearn.utils._cython_blas', 'sklearn.neighbors.typedefs', 'sklearn.neighbors.quad_tree', 'sklearn.tree', 'sklearn.tree._utils'],
|
||||
hookspath=['server/gui/'],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
exe = EXE(pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='cellxgene',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False , icon='server/gui/images/cxg_icons.icns')
|
||||
coll = COLLECT(exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='cellxgene')
|
||||
app = BUNDLE(coll,
|
||||
name='cellxgene.app',
|
||||
icon='server/gui/images/cxg_icons.icns',
|
||||
bundle_identifier=None)
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- mode: python -*-
|
||||
|
||||
block_cipher = None
|
||||
|
||||
|
||||
a = Analysis(['server\\gui\\main.py'],
|
||||
pathex=['C:\\Users\\Charlotte\\Documents\\git\\cellxgene'],
|
||||
binaries=[],
|
||||
datas=[('server/app/web/templates/', 'server/app/web/templates'), ('server/app/web/static/', 'server/app/web/static')],
|
||||
hiddenimports=[],
|
||||
hookspath=['server/gui/'],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
exe = EXE(pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='cellxgene',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False , icon='server\\gui\\images\\icon.ico')
|
||||
coll = COLLECT(exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
name='cellxgene')
|
||||
@@ -0,0 +1,6 @@
|
||||
export const jest_env = process.env.JEST_ENV || "dev";
|
||||
export const appPort = process.env.JEST_CXG_PORT || 3000;
|
||||
export const appUrlBase = `http://localhost:${appPort}`;
|
||||
export const DEV = jest_env === "dev";
|
||||
export const DEBUG = jest_env === "debug";
|
||||
export const DATASET = "pbmc3k";
|
||||
@@ -0,0 +1,128 @@
|
||||
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: "1537"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
diffexp: {
|
||||
cellset1: [
|
||||
{ kind: "categorical", metadata: "louvain", values: ["B cells"] }
|
||||
],
|
||||
cellset2: [
|
||||
{
|
||||
kind: "categorical",
|
||||
metadata: "louvain",
|
||||
values: ["CD4 T cells", "NK cells"]
|
||||
}
|
||||
],
|
||||
"gene-results": [
|
||||
"HLA-DRB1",
|
||||
"HLA-DPB1",
|
||||
"CD79A",
|
||||
"HLA-DPA1",
|
||||
"HLA-DQA1",
|
||||
"CD79B",
|
||||
"HLA-DQB1",
|
||||
"MS4A1",
|
||||
"IL32",
|
||||
"CD37"
|
||||
]
|
||||
},
|
||||
|
||||
genes: {
|
||||
bulkadd: ["S100A8", "FCGR3A", "LGALS2", "GSTP1"],
|
||||
search: "ACD"
|
||||
},
|
||||
subset: {
|
||||
cellset1: [
|
||||
{
|
||||
kind: "categorical",
|
||||
metadata: "louvain",
|
||||
values: ["B cells", "Megakaryocytes"]
|
||||
}
|
||||
],
|
||||
count: "357",
|
||||
categorical: {
|
||||
louvain: {
|
||||
"B cells": "342",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
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,343 @@
|
||||
/*
|
||||
Smoke test suite that will be run in Travis CI
|
||||
|
||||
Tests included in this file are expected to be relatively stable and test core features
|
||||
*/
|
||||
import puppeteer from "puppeteer";
|
||||
import { appUrlBase, DEBUG, DEV, DATASET } from "./config";
|
||||
import { puppeteerUtils, cellxgeneActions } from "./puppeteerUtils";
|
||||
import { datasets } from "./data";
|
||||
|
||||
let browser, page, utils, cxgActions, spy;
|
||||
const browserViewport = { width: 1280, height: 960 };
|
||||
let data = datasets[DATASET];
|
||||
|
||||
if (DEBUG) jest.setTimeout(100000);
|
||||
if (DEV) jest.setTimeout(10000);
|
||||
|
||||
beforeAll(async () => {
|
||||
const browserParams = DEV
|
||||
? { headless: false, slowMo: 5 }
|
||||
: DEBUG
|
||||
? { headless: false, slowMo: 100, devtools: true }
|
||||
: {};
|
||||
browser = await puppeteer.launch(browserParams);
|
||||
page = await browser.newPage();
|
||||
await page.setViewport(browserViewport);
|
||||
if (DEV || DEBUG) {
|
||||
page.on("console", async msg => {
|
||||
// If there is a console.error but an error is not thrown, this will ensure the test fails
|
||||
if (msg.type() === "error") {
|
||||
const errorMsgText = await Promise.all(
|
||||
// TODO can we do this without internal properties?
|
||||
msg.args().map(arg => arg._remoteObject.description)
|
||||
);
|
||||
throw new Error(`Console error: ${errorMsgText}`);
|
||||
}
|
||||
console.log(`PAGE LOG: ${msg.text()}`);
|
||||
});
|
||||
}
|
||||
page.on("pageerror", err => {
|
||||
throw new Error(`Console error: ${err}`);
|
||||
});
|
||||
utils = puppeteerUtils(page);
|
||||
cxgActions = cellxgeneActions(page);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await page.goto(appUrlBase);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (!DEBUG) {
|
||||
browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("did launch", () => {
|
||||
test("page launched", async () => {
|
||||
let el = await utils.getOneElementInnerHTML("[data-testid='header']");
|
||||
expect(el).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(`category-expand-${label}`);
|
||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.categorical[label])
|
||||
);
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.categorical[label])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("continuous data appears", async () => {
|
||||
for (const label in data.continuous) {
|
||||
await utils.waitByID(`histogram-${label}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cell selection", () => {
|
||||
test("selects all cells cellset 1", async () => {
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(data.dataframe.nObs);
|
||||
});
|
||||
|
||||
test("selects all cells cellset 2", async () => {
|
||||
const cellCount = await cxgActions.cellSet(2);
|
||||
expect(cellCount).toBe(data.dataframe.nObs);
|
||||
});
|
||||
|
||||
test("selects cells via lasso", async () => {
|
||||
for (const cellset of data.cellsets.lasso) {
|
||||
const cellset1 = await cxgActions.calcDragCoordinates(
|
||||
"layout-graph",
|
||||
cellset["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag("layout-graph", cellset1.start, cellset1.end, true);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
|
||||
test("selects cells via categorical", async () => {
|
||||
for (const cellset of data.cellsets.categorical) {
|
||||
await utils.clickOn(`category-expand-${cellset.metadata}`);
|
||||
await utils.clickOn(`category-select-${cellset.metadata}`);
|
||||
for (const val of cellset.values) {
|
||||
await utils.clickOn(
|
||||
`categorical-value-select-${cellset.metadata}-${val}`
|
||||
);
|
||||
}
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
|
||||
test("selects cells via continuous", async () => {
|
||||
for (const cellset of data.cellsets.continuous) {
|
||||
const histId = `histogram-${cellset.metadata}-plot-brush`;
|
||||
const coords = await cxgActions.calcDragCoordinates(
|
||||
histId,
|
||||
cellset["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(histId, coords.start, coords.end);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("gene entry", () => {
|
||||
test("search for single gene", async () => {
|
||||
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
|
||||
await utils.typeInto("gene-search", data.genes.search);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(
|
||||
`[data-testid='histogram-${data.genes.search}']`
|
||||
);
|
||||
});
|
||||
|
||||
test("bulk add genes", async () => {
|
||||
await cxgActions.reset();
|
||||
const testGenes = data.genes.bulkadd;
|
||||
await utils.clickOn("section-bulk-add");
|
||||
await utils.typeInto("input-bulk-add", testGenes.join(","));
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const allHistograms = await cxgActions.getAllHistograms(
|
||||
"histogram-user-gene",
|
||||
testGenes
|
||||
);
|
||||
expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
|
||||
expect(allHistograms.length).toEqual(testGenes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffexp", () => {
|
||||
test("selects cells, saves them and performs diffexp", async () => {
|
||||
for (const select of data.diffexp.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await cxgActions.cellSet(1);
|
||||
for (const select of data.diffexp.cellset2) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await cxgActions.cellSet(2);
|
||||
await utils.clickOn("diffexp-button");
|
||||
const allHistograms = await cxgActions.getAllHistograms(
|
||||
"histogram-diffexp",
|
||||
data.diffexp["gene-results"]
|
||||
);
|
||||
expect(allHistograms).toEqual(
|
||||
expect.arrayContaining(data.diffexp["gene-results"])
|
||||
);
|
||||
expect(allHistograms.length).toEqual(data.diffexp["gene-results"].length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subset/reset", () => {
|
||||
test("subset - cell count matches", async () => {
|
||||
for (const select of data.subset.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await utils.clickOn("subset-button");
|
||||
for (const label in data.subset.categorical) {
|
||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.subset.categorical[label])
|
||||
);
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.subset.categorical[label])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reset after subset", async () => {
|
||||
for (const select of data.subset.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await utils.clickOn("subset-button");
|
||||
for (const label in data.subset.categorical) {
|
||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.subset.categorical[label])
|
||||
);
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.subset.categorical[label])
|
||||
);
|
||||
}
|
||||
await cxgActions.reset();
|
||||
for (const label in data.categorical) {
|
||||
await utils.waitByID(`category-${label}`);
|
||||
const categoryName = await utils.getOneElementInnerText(
|
||||
`[data-testid="category-${label}"]`
|
||||
);
|
||||
expect(categoryName).toMatch(label);
|
||||
const categories = await cxgActions.getAllCategoriesAndCounts(label);
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.categorical[label])
|
||||
);
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.categorical[label])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("lasso after subset", async () => {
|
||||
for (const select of data.subset.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await cxgActions.selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
await utils.clickOn("subset-button");
|
||||
const lassoSelection = await cxgActions.calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.subset.lasso["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(
|
||||
"layout-graph",
|
||||
lassoSelection.start,
|
||||
lassoSelection.end,
|
||||
true
|
||||
);
|
||||
const cellCount = await cxgActions.cellSet(1);
|
||||
expect(cellCount).toBe(data.subset.lasso.count);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scatter plot", () => {
|
||||
test("scatter plot appears", async () => {
|
||||
await cxgActions.reset();
|
||||
const testGenes = data.scatter.genes;
|
||||
await utils.clickOn("section-bulk-add");
|
||||
await utils.typeInto("input-bulk-add", Object.values(testGenes).join(","));
|
||||
await page.keyboard.press("Enter");
|
||||
await utils.clickOn(`plot-x-${data.scatter.genes.x}`);
|
||||
await utils.clickOn(`plot-y-${data.scatter.genes.y}`);
|
||||
await utils.waitByID("scatterplot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipping", () => {
|
||||
test("clip continuous", async () => {
|
||||
await cxgActions.clip(data.clip.min, data.clip.max);
|
||||
const histId = `histogram-${data.clip.metadata}-plot-brush`;
|
||||
const coords = await cxgActions.calcDragCoordinates(
|
||||
histId,
|
||||
data.clip["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(histId, 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 histId = `histogram-${data.clip.gene}-plot-brush`;
|
||||
const coords = await cxgActions.calcDragCoordinates(
|
||||
histId,
|
||||
data.clip["coordinates-as-percent"]
|
||||
);
|
||||
await cxgActions.drag(histId, 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);`);
|
||||
});
|
||||
});
|
||||
@@ -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,211 @@
|
||||
export const puppeteerUtils = puppeteerPage => ({
|
||||
async waitByID(testid, props = {}) {
|
||||
return await puppeteerPage.waitForSelector(
|
||||
`[data-testid='${testid}']`,
|
||||
props
|
||||
);
|
||||
},
|
||||
|
||||
async waitByClass(testclass, props = {}) {
|
||||
return await puppeteerPage.waitForSelector(
|
||||
`[data-testclass='${testclass}']`,
|
||||
props
|
||||
);
|
||||
},
|
||||
|
||||
async waitForAllByIds(testids, props = {}) {
|
||||
await Promise.all(
|
||||
testids.map(testid =>
|
||||
puppeteerPage.waitForSelector(`[data-testid='${testid}']`)
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
async getAllByClass(testclass, props = {}) {
|
||||
const elements = await puppeteerPage.$$eval(
|
||||
`[data-testclass=${testclass}]`,
|
||||
els => {
|
||||
return els.map(el => {
|
||||
return el.dataset.testid;
|
||||
});
|
||||
}
|
||||
);
|
||||
return elements;
|
||||
},
|
||||
|
||||
async typeInto(testid, text) {
|
||||
// only works for text without special characters
|
||||
await this.waitByID(testid);
|
||||
const selector = `[data-testid='${testid}']`;
|
||||
// type ahead can be annoying if you don't pause before you type
|
||||
await puppeteerPage.click(selector);
|
||||
await puppeteerPage.waitFor(200);
|
||||
await puppeteerPage.type(selector, text);
|
||||
},
|
||||
|
||||
async clearInputAndTypeInto(testid, text) {
|
||||
await this.waitByID(testid);
|
||||
const selector = `[data-testid='${testid}']`;
|
||||
// only works for text without special characters
|
||||
// type ahead can be annoying if you don't pause before you type
|
||||
await puppeteerPage.click(selector);
|
||||
await puppeteerPage.waitFor(200);
|
||||
// select all
|
||||
|
||||
await puppeteerPage.click(selector, { clickCount: 3 });
|
||||
await puppeteerPage.keyboard.type("Backspace");
|
||||
await puppeteerPage.type(selector, text);
|
||||
},
|
||||
|
||||
async clickOn(testid) {
|
||||
await this.waitByID(testid);
|
||||
await puppeteerPage.click(`[data-testid='${testid}']`);
|
||||
await puppeteerPage.waitFor(50);
|
||||
},
|
||||
|
||||
async getOneElementInnerHTML(selector) {
|
||||
await puppeteerPage.waitForSelector(selector);
|
||||
let text = await puppeteerPage.$eval(selector, el => el.innerHTML);
|
||||
return text;
|
||||
},
|
||||
|
||||
async getOneElementInnerText(selector) {
|
||||
await puppeteerPage.waitForSelector(selector);
|
||||
let text = await puppeteerPage.$eval(selector, el => el.innerText);
|
||||
return text;
|
||||
}
|
||||
});
|
||||
|
||||
export const cellxgeneActions = puppeteerPage => ({
|
||||
async drag(testid, start, end, lasso = false) {
|
||||
const layout = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
||||
const elBox = await layout.boxModel();
|
||||
const x1 = elBox.content[0].x + start.x;
|
||||
const x2 = elBox.content[0].x + end.x;
|
||||
const y1 = elBox.content[0].y + start.y;
|
||||
const y2 = elBox.content[0].y + end.y;
|
||||
await puppeteerPage.mouse.move(x1, y1);
|
||||
await puppeteerPage.mouse.down();
|
||||
if (lasso) {
|
||||
await puppeteerPage.mouse.move(x2, y1);
|
||||
await puppeteerPage.mouse.move(x2, y2);
|
||||
await puppeteerPage.mouse.move(x1, y2);
|
||||
await puppeteerPage.mouse.move(x1, y1);
|
||||
} else {
|
||||
await puppeteerPage.mouse.move(x2, y2);
|
||||
}
|
||||
await puppeteerPage.mouse.up();
|
||||
},
|
||||
|
||||
async getAllHistograms(testclass, testids) {
|
||||
const histTestIds = testids.map(tid => `histogram-${tid}`);
|
||||
// these load asynchronously, so we need to wait for each histogram individually
|
||||
await puppeteerUtils(puppeteerPage).waitForAllByIds(histTestIds);
|
||||
const allHistograms = await puppeteerUtils(puppeteerPage).getAllByClass(
|
||||
testclass
|
||||
);
|
||||
return allHistograms.map(hist =>
|
||||
hist.substr("histogram_".length, hist.length)
|
||||
);
|
||||
},
|
||||
|
||||
async getAllCategoriesAndCounts(category) {
|
||||
await puppeteerUtils(puppeteerPage).waitByClass("categorical-row");
|
||||
const categories = await puppeteerPage.$$eval(
|
||||
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
|
||||
els => {
|
||||
let result = {};
|
||||
els.forEach(el => {
|
||||
const cat = el.querySelector("[data-testclass='categorical-value']")
|
||||
.innerText;
|
||||
const count = el.querySelector(
|
||||
"[data-testclass='categorical-value-count']"
|
||||
).innerText;
|
||||
result[cat] = count;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
);
|
||||
return categories;
|
||||
},
|
||||
|
||||
async cellSet(num) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn(`cellset-button-${num}`);
|
||||
return await puppeteerUtils(puppeteerPage).getOneElementInnerText(
|
||||
`[data-testid='cellset-count-${num}']`
|
||||
);
|
||||
},
|
||||
|
||||
async resetCategory(category) {
|
||||
const checkboxId = `category-select-${category}`;
|
||||
await puppeteerUtils(puppeteerPage).waitByID(checkboxId);
|
||||
const checkedPseudoclass = await puppeteerPage.$eval(
|
||||
`[data-testid='${checkboxId}']`,
|
||||
el => {
|
||||
return el.matches(":checked");
|
||||
}
|
||||
);
|
||||
if (!checkedPseudoclass) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn(checkboxId);
|
||||
}
|
||||
try {
|
||||
const categoryRow = await puppeteerUtils(puppeteerPage).waitByID(
|
||||
`category-expand-${category}`
|
||||
);
|
||||
const isExpanded = await categoryRow.$(
|
||||
"[data-testclass='category-expand-is-expanded']"
|
||||
);
|
||||
if (isExpanded) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn(
|
||||
`category-expand-${category}`
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async calcDragCoordinates(testid, coordinateAsPercent) {
|
||||
const el = await puppeteerUtils(puppeteerPage).waitByID(testid);
|
||||
const size = await el.boxModel();
|
||||
const coords = {
|
||||
start: {
|
||||
x: Math.floor(size.width * coordinateAsPercent.x1),
|
||||
y: Math.floor(size.height * coordinateAsPercent.y1)
|
||||
},
|
||||
end: {
|
||||
x: Math.floor(size.width * coordinateAsPercent.x2),
|
||||
y: Math.floor(size.height * coordinateAsPercent.y2)
|
||||
}
|
||||
};
|
||||
return coords;
|
||||
},
|
||||
|
||||
async selectCategory(category, values, reset = true) {
|
||||
if (reset) await this.resetCategory(category);
|
||||
await puppeteerUtils(puppeteerPage).clickOn(`category-expand-${category}`);
|
||||
await puppeteerUtils(puppeteerPage).clickOn(`category-select-${category}`);
|
||||
for (const val of values) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn(
|
||||
`categorical-value-select-${category}-${val}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async reset() {
|
||||
await puppeteerUtils(puppeteerPage).clickOn("reset");
|
||||
// loading state never actually happens, reset is too fast
|
||||
await page.waitFor(200);
|
||||
},
|
||||
|
||||
async clip(min = 0, max = 100) {
|
||||
await puppeteerUtils(puppeteerPage).clickOn("visualization-settings");
|
||||
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
|
||||
"clip-min-input",
|
||||
min
|
||||
);
|
||||
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
|
||||
"clip-max-input",
|
||||
max
|
||||
);
|
||||
await puppeteerUtils(puppeteerPage).clickOn("clip-commit");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import cascadeReducers from "../../src/reducers/cascade";
|
||||
|
||||
describe("create", () => {
|
||||
test("from Array", () => {
|
||||
expect(cascadeReducers([["foo", () => 0]])).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("from Map", () => {
|
||||
expect(cascadeReducers(new Map([["foo", () => 0]]))).toBeInstanceOf(
|
||||
Function
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascade", () => {
|
||||
test("expected arguments provided & cascade ordering", () => {
|
||||
const topLevelState = {};
|
||||
const topLevelAction = { type: "test" };
|
||||
|
||||
const reducer = cascadeReducers([
|
||||
[
|
||||
"foo",
|
||||
(currentState, action, nextSharedState, prevSharedState) => {
|
||||
expect(currentState).toBeUndefined();
|
||||
expect(action).toEqual(topLevelAction);
|
||||
expect(nextSharedState).toStrictEqual({});
|
||||
expect(prevSharedState).toBe(topLevelState);
|
||||
return 0;
|
||||
}
|
||||
],
|
||||
[
|
||||
"bar",
|
||||
(currentState, action, nextSharedState, prevSharedState) => {
|
||||
expect(currentState).toBeUndefined();
|
||||
expect(action).toEqual(topLevelAction);
|
||||
expect(nextSharedState).toStrictEqual({ foo: 0 });
|
||||
expect(prevSharedState).toBe(topLevelState);
|
||||
return 99;
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
const nextState = reducer(topLevelState, topLevelAction);
|
||||
expect(nextState).toStrictEqual({ foo: 0, bar: 99 });
|
||||
expect(topLevelState).toStrictEqual({});
|
||||
expect(topLevelAction).toStrictEqual({ type: "test" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import undoable from "../../src/reducers/undoable";
|
||||
|
||||
describe("create", () => {
|
||||
test("no keys", () => {
|
||||
expect(() => undoable(() => {})).toThrow();
|
||||
expect(() => undoable(() => {}, null)).toThrow();
|
||||
expect(() => undoable(() => {}, [])).toThrow();
|
||||
expect(() => undoable(() => {}, [], {})).toThrow();
|
||||
});
|
||||
|
||||
test("simple", () => {
|
||||
expect(undoable(() => {}, ["foo"])).toBeInstanceOf(Function);
|
||||
expect(undoable(() => {}, ["foo"], {})).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("handles undefined initial state", () => {
|
||||
expect(
|
||||
undoable(() => {}, ["a"])(undefined, { type: "test" })
|
||||
).toMatchObject({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo", () => {
|
||||
test("expected state modifications", () => {
|
||||
const initialState = { a: 0, b: 1000 };
|
||||
const reducer = state => {
|
||||
return { a: state.a + 1, b: state.b + 1 };
|
||||
};
|
||||
const undoableReducer = undoable(reducer, ["a"]);
|
||||
|
||||
const s1 = undoableReducer(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// test that only specified keys are undone
|
||||
const s2 = undoableReducer(s1, { type: "@@undoable/undo" });
|
||||
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||
|
||||
// test backstop when no more history
|
||||
const s3 = undoableReducer(s2, { type: "@@undoable/undo" });
|
||||
expect(s3).toMatchObject({ a: 0, b: 1001 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("redo", () => {
|
||||
const initialState = { a: 0, b: 1000 };
|
||||
const reducer = state => {
|
||||
return { a: state.a + 1, b: state.b + 1 };
|
||||
};
|
||||
let UR;
|
||||
|
||||
beforeEach(() => {
|
||||
UR = undoable(reducer, ["a"]);
|
||||
});
|
||||
|
||||
test("expected state modifications", () => {
|
||||
const s1 = UR(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// verify undo->redo reverts state.
|
||||
const s2 = UR(UR(s1, { type: "@@undoable/undo" }), {
|
||||
type: "@@undoable/redo"
|
||||
});
|
||||
expect(s2).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// verify backstop when no redo future
|
||||
const s3 = UR(s2, { type: "@@undoable/redo" });
|
||||
expect(s3).toMatchObject({ a: 1, b: 1001 });
|
||||
});
|
||||
|
||||
test("history cleared", () => {
|
||||
// verify future cleared upon a normal state transition
|
||||
const s1 = UR(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
const s2 = UR(s1, { type: "@@undoable/undo" });
|
||||
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||
const s3 = UR(s2, { type: "test" });
|
||||
expect(s3).toMatchObject({ a: 1, b: 1002 });
|
||||
const s4 = UR(s3, { type: "@@undoable/redo" });
|
||||
expect(s4).toMatchObject({ a: 1, b: 1002 });
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
TODO:
|
||||
- historyLimit is enforced
|
||||
- action filters
|
||||
*/
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Define globals which are present in the client, but not in node (and therefore not in
|
||||
the jest test environment).
|
||||
*/
|
||||
|
||||
import { TextDecoder, TextEncoder } from "util";
|
||||
|
||||
global.TextDecoder = TextDecoder;
|
||||
global.TextEncoder = TextEncoder;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { rangeEncodeIndices } from "../../src/util/actionHelpers";
|
||||
|
||||
describe("rangeEncodeIndices", () => {
|
||||
test("small array edge cases", () => {
|
||||
expect(rangeEncodeIndices([])).toMatchObject([]);
|
||||
expect(rangeEncodeIndices([1])).toMatchObject([1]);
|
||||
expect(rangeEncodeIndices([1, 99])).toMatchObject([1, 99]);
|
||||
expect(rangeEncodeIndices([99, 1])).toMatchObject([1, 99]);
|
||||
expect(rangeEncodeIndices([1, 100, 4])).toMatchObject([1, 4, 100]);
|
||||
});
|
||||
|
||||
test("sorted flag", () => {
|
||||
expect(rangeEncodeIndices([1, 9, 432], 10, true)).toMatchObject([
|
||||
1,
|
||||
9,
|
||||
432
|
||||
]);
|
||||
expect(rangeEncodeIndices([1, 9, 432], 10, false)).toMatchObject([
|
||||
1,
|
||||
9,
|
||||
432
|
||||
]);
|
||||
expect(rangeEncodeIndices([0, 1, 2, 3, 9, 10, 432], 2, true)).toMatchObject(
|
||||
[[0, 3], [9, 10], 432]
|
||||
);
|
||||
expect(
|
||||
rangeEncodeIndices([0, 1, 2, 3, 9, 10, 432], 2, false)
|
||||
).toMatchObject([[0, 3], [9, 10], 432]);
|
||||
});
|
||||
|
||||
test("begin or end edge cases", () => {
|
||||
expect(
|
||||
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12, 99], 3, false)
|
||||
).toMatchObject([[3, 7], [10, 12], 99]);
|
||||
expect(
|
||||
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12], 3, false)
|
||||
).toMatchObject([[3, 7], [10, 12]]);
|
||||
expect(
|
||||
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12], 3, false)
|
||||
).toMatchObject([0, [3, 7], [10, 12]]);
|
||||
expect(
|
||||
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12, 99], 3, false)
|
||||
).toMatchObject([0, [3, 7], [10, 12], 99]);
|
||||
});
|
||||
|
||||
test("minRangeLength", () => {
|
||||
expect(
|
||||
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12, 99], 4, false)
|
||||
).toMatchObject([[3, 7], 10, 11, 12, 99]);
|
||||
expect(
|
||||
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12], 4, false)
|
||||
).toMatchObject([[3, 7], 10, 11, 12]);
|
||||
expect(
|
||||
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12], 4, false)
|
||||
).toMatchObject([0, [3, 7], 10, 11, 12]);
|
||||
expect(
|
||||
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12, 99], 4, false)
|
||||
).toMatchObject([0, [3, 7], 10, 11, 12, 99]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,731 @@
|
||||
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 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 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]);
|
||||
});
|
||||
});
|
||||
|
||||
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,68 @@
|
||||
/*
|
||||
Test cases for nameCreators.js.
|
||||
*/
|
||||
import {
|
||||
layoutDimensionName,
|
||||
obsAnnoDimensionName,
|
||||
diffexpDimensionName,
|
||||
userDefinedDimensionName,
|
||||
makeContinuousDimensionName
|
||||
} from "../../src/util/nameCreators";
|
||||
|
||||
describe("nameCreators", () => {
|
||||
const nameCreators = [
|
||||
layoutDimensionName,
|
||||
obsAnnoDimensionName,
|
||||
diffexpDimensionName,
|
||||
userDefinedDimensionName
|
||||
];
|
||||
|
||||
test("check for namespace isolation", () => {
|
||||
const foo = "foo";
|
||||
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(foo));
|
||||
|
||||
const bar = "bar";
|
||||
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(fn(bar)));
|
||||
|
||||
nameCreators.forEach(fn => {
|
||||
const allOtherFn = nameCreators.filter(elmnt => elmnt !== fn);
|
||||
allOtherFn.forEach(otherFn => expect(fn(foo)).not.toBe(otherFn(foo)));
|
||||
});
|
||||
});
|
||||
|
||||
test("check for legal keys", () => {
|
||||
/* need namespace creators to return strings only */
|
||||
nameCreators.forEach(fn => expect(fn("X")).toMatch(/X/));
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeContinuousDimensionName", () => {
|
||||
test("namespaces", () => {
|
||||
expect(makeContinuousDimensionName({ isObs: true }, "OK")).toMatch(/OK/);
|
||||
expect(makeContinuousDimensionName({ isDiffExp: true }, "OK")).toMatch(
|
||||
/OK/
|
||||
);
|
||||
expect(makeContinuousDimensionName({ isUserDefined: true }, "OK")).toMatch(
|
||||
/OK/
|
||||
);
|
||||
});
|
||||
|
||||
test("error handling", () => {
|
||||
expect(() => makeContinuousDimensionName({}, "oops")).toThrow();
|
||||
expect(() =>
|
||||
makeContinuousDimensionName(
|
||||
{ isObs: false, isDiffExp: false, isUserDefined: false },
|
||||
"oops"
|
||||
)
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
makeContinuousDimensionName({ isObs: true }, "OK")
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
makeContinuousDimensionName({ isDiffExp: true }, "OK")
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
makeContinuousDimensionName({ isUserDefined: true }, "OK")
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import quantile from "../../src/util/quantile";
|
||||
|
||||
describe("quantile", () => {
|
||||
test("single q", () => {
|
||||
const arr = new Float32Array([9, 3, 5, 6, 0]);
|
||||
expect(quantile([1.0], arr)).toMatchObject([9]);
|
||||
expect(quantile([0.9], arr)).toMatchObject([9]);
|
||||
expect(quantile([0.8], arr)).toMatchObject([9]);
|
||||
expect(quantile([0.7], arr)).toMatchObject([6]);
|
||||
expect(quantile([0.6], arr)).toMatchObject([6]);
|
||||
expect(quantile([0.5], arr)).toMatchObject([5]);
|
||||
expect(quantile([0.4], arr)).toMatchObject([5]);
|
||||
expect(quantile([0.3], arr)).toMatchObject([3]);
|
||||
expect(quantile([0.2], arr)).toMatchObject([3]);
|
||||
expect(quantile([0.1], arr)).toMatchObject([0]);
|
||||
expect(quantile([0], arr)).toMatchObject([0]);
|
||||
});
|
||||
|
||||
test("multi q", () => {
|
||||
const arr = new Float32Array([9, 3, 5, 6, 0]);
|
||||
expect(quantile([0, 0.25, 0.5, 0.75, 1.0], arr)).toMatchObject([
|
||||
0,
|
||||
3,
|
||||
5,
|
||||
6,
|
||||
9
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { range, rangeFill } 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])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/* eslint no-bitwise: "off" */
|
||||
import _ from "lodash";
|
||||
import { flatbuffers } from "flatbuffers";
|
||||
import { NetEncoding } from "../../../src/util/stateManager/matrix_generated";
|
||||
|
||||
/*
|
||||
test data mocking REST 0.2 API responses. Used in several tests.
|
||||
*/
|
||||
const nObs = 10;
|
||||
const nVar = 32;
|
||||
const field4Categories = [83, true, "foo", 2.222222];
|
||||
const fieldDCategories = [99, false, "mumble", 3.1415];
|
||||
|
||||
const aConfigResponse = {
|
||||
config: {
|
||||
features: [
|
||||
{ method: "POST", path: "/cluster/", available: false },
|
||||
{ method: "POST", path: "/layout/", available: false },
|
||||
{ method: "POST", path: "/diffexp/", available: false },
|
||||
{ method: "POST", path: "/saveLocal/", available: false }
|
||||
],
|
||||
displayNames: {
|
||||
engine: "the little engine that could",
|
||||
dataset: "all your zeros are mine"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const aSchemaResponse = {
|
||||
schema: {
|
||||
dataframe: {
|
||||
nObs,
|
||||
nVar,
|
||||
type: "float32"
|
||||
},
|
||||
annotations: {
|
||||
obs: {
|
||||
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 anAnnotationsObsJSONResponse = {
|
||||
names: ["name", "field1", "field2", "field3", "field4"],
|
||||
data: _()
|
||||
.range(nObs)
|
||||
.map(idx => [
|
||||
idx,
|
||||
`obs${idx}`,
|
||||
2 * idx,
|
||||
idx + 0.0133,
|
||||
!!(idx & 1),
|
||||
field4Categories[idx % field4Categories.length]
|
||||
])
|
||||
.value()
|
||||
};
|
||||
|
||||
const anAnnotationsVarJSONResponse = {
|
||||
names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"],
|
||||
data: _()
|
||||
.range(nVar)
|
||||
.map(idx => [
|
||||
idx,
|
||||
10 * idx,
|
||||
idx + 2.90143,
|
||||
!!(idx & 1),
|
||||
fieldDCategories[idx % fieldDCategories.length],
|
||||
`var${idx}`
|
||||
])
|
||||
.value()
|
||||
};
|
||||
|
||||
function encodeTypedArray(builder, uType, uData) {
|
||||
const uTypeName = NetEncoding.TypedArray[uType];
|
||||
const ArrayType = NetEncoding[uTypeName];
|
||||
const dv = ArrayType.createDataVector(builder, uData);
|
||||
builder.startObject(1);
|
||||
builder.addFieldOffset(0, dv, 0);
|
||||
return builder.endObject();
|
||||
}
|
||||
|
||||
function encodeMatrix(columns, colIndex = undefined) {
|
||||
/*
|
||||
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],
|
||||
obs: _()
|
||||
.range(nObs)
|
||||
.map(idx => [idx, Math.random(), Math.random(), Math.random()])
|
||||
.value()
|
||||
};
|
||||
|
||||
export {
|
||||
aLayoutFBSResponse as layoutObs,
|
||||
aDataObsResponse as dataObs,
|
||||
anAnnotationsVarFBSResponse as annotationsVar,
|
||||
anAnnotationsObsFBSResponse as annotationsObs,
|
||||
aSchemaResponse as schema,
|
||||
aConfigResponse as config
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as Universe from "../../../src/util/stateManager/universe";
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
import * as REST from "./sampleResponses";
|
||||
|
||||
describe("createUniverseFromResponse", () => {
|
||||
/*
|
||||
test createUniverseFromResponse - this function converts
|
||||
a set of REST 0.2 responses into a "new" Universe.
|
||||
|
||||
createUniverseFromResponse(
|
||||
configResponse,
|
||||
schemaResponse,
|
||||
annotationsObsResponse,
|
||||
annotationsVarResponse,
|
||||
layoutObsResponse
|
||||
) --> Universe
|
||||
|
||||
where:
|
||||
configResponse: GET /.../config
|
||||
schemaResponse: GET /.../schema
|
||||
annotationsObsResponse: GET /.../annotations/obs
|
||||
annotationsVarResponse: GET /.../annotations/var
|
||||
layoutObsResponse: GET /.../layout/obs
|
||||
|
||||
See spec in docs/REST_API.md.
|
||||
*/
|
||||
|
||||
test("create from test data", () => {
|
||||
/*
|
||||
create a universe from sample data nad validate its shape & contents
|
||||
*/
|
||||
const { nObs, nVar } = REST.schema.schema.dataframe;
|
||||
const universe = Universe.createUniverseFromResponse(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
);
|
||||
|
||||
expect(universe).toBeDefined();
|
||||
expect(universe).toMatchObject(
|
||||
expect.objectContaining({
|
||||
nObs,
|
||||
nVar,
|
||||
schema: REST.schema.schema,
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe)
|
||||
})
|
||||
);
|
||||
|
||||
expect(universe.obsAnnotations.dims).toEqual([
|
||||
nObs,
|
||||
REST.schema.schema.annotations.obs.columns.length
|
||||
]);
|
||||
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
|
||||
expect(universe.obsLayout.colIndex.keys()).toEqual(
|
||||
universe.schema.layout.obs[0].dims
|
||||
);
|
||||
expect(universe.varAnnotations.dims).toEqual([
|
||||
nVar,
|
||||
REST.schema.schema.annotations.var.columns.length
|
||||
]);
|
||||
expect(universe.varData.isEmpty()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import _ from "lodash";
|
||||
import * as Universe from "../../../src/util/stateManager/universe";
|
||||
import * as World from "../../../src/util/stateManager/world";
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||
import { DimTypes } from "../../../src/util/typedCrossfilter/crossfilter";
|
||||
import * as REST from "./sampleResponses";
|
||||
import {
|
||||
obsAnnoDimensionName,
|
||||
layoutDimensionName
|
||||
} from "../../../src/util/nameCreators";
|
||||
|
||||
/*
|
||||
Helper - creates universe, world, corssfilter and dimensionMap from
|
||||
the default REST test response.
|
||||
*/
|
||||
const defaultBigBang = () => {
|
||||
/* create unverse, world, crossfilter and dimensionMap */
|
||||
/* create universe */
|
||||
const universe = Universe.createUniverseFromResponse(
|
||||
_.cloneDeep(REST.config),
|
||||
_.cloneDeep(REST.schema),
|
||||
_.cloneDeep(REST.annotationsObs),
|
||||
_.cloneDeep(REST.annotationsVar),
|
||||
_.cloneDeep(REST.layoutObs)
|
||||
);
|
||||
/* create world */
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
/* create crossfilter */
|
||||
const crossfilter = World.createObsDimensions(
|
||||
new Crossfilter(world.obsAnnotations),
|
||||
world,
|
||||
REST.schema.schema.layout.obs[0].dims
|
||||
);
|
||||
|
||||
return {
|
||||
universe,
|
||||
world,
|
||||
crossfilter
|
||||
};
|
||||
};
|
||||
|
||||
describe("createWorldFromEntireUniverse", () => {
|
||||
test("create from REST sample", () => {
|
||||
const universe = Universe.createUniverseFromResponse(
|
||||
_.cloneDeep(REST.config),
|
||||
_.cloneDeep(REST.schema),
|
||||
_.cloneDeep(REST.annotationsObs),
|
||||
_.cloneDeep(REST.annotationsVar),
|
||||
_.cloneDeep(REST.layoutObs)
|
||||
);
|
||||
expect(universe).toBeDefined();
|
||||
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
expect(world).toBeDefined();
|
||||
|
||||
expect(world).toMatchObject(
|
||||
expect.objectContaining({
|
||||
nObs: universe.nObs,
|
||||
nVar: universe.nVar,
|
||||
schema: universe.schema,
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
clipQuantiles: { min: 0, max: 1 },
|
||||
unclipped: {
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe)
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorldFromCurrentSelection", () => {
|
||||
test("create from REST sample", () => {
|
||||
const {
|
||||
universe,
|
||||
world: originalWorld,
|
||||
crossfilter: originalCrossfilter
|
||||
} = defaultBigBang();
|
||||
|
||||
/* mock a selection */
|
||||
const crossfilter = originalCrossfilter
|
||||
.select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 })
|
||||
.select(obsAnnoDimensionName("field3"), {
|
||||
mode: "exact",
|
||||
values: [false]
|
||||
});
|
||||
|
||||
/* create the world from the selection */
|
||||
const world = World.createWorldBySelection(
|
||||
universe,
|
||||
originalWorld,
|
||||
crossfilter
|
||||
);
|
||||
expect(world).toBeDefined();
|
||||
expect(world.nObs).toEqual(crossfilter.countSelected());
|
||||
|
||||
/*
|
||||
calculate expected values and match against result
|
||||
*/
|
||||
|
||||
/* matchFilter must match the dimension filters above */
|
||||
const matchFilter = (df, row) => {
|
||||
const field1 = df.at(row, "field1");
|
||||
const field3 = df.at(row, "field3");
|
||||
return field1 >= 0 && field1 < 5 && !field3;
|
||||
};
|
||||
const matchingIndices = _()
|
||||
.range(universe.nObs)
|
||||
.filter(idx => matchFilter(universe.obsAnnotations, idx))
|
||||
.value();
|
||||
|
||||
expect(world).toMatchObject(
|
||||
expect.objectContaining({
|
||||
nObs: matchingIndices.length,
|
||||
nVar: universe.nVar,
|
||||
schema: universe.schema,
|
||||
clipQuantiles: { min: 0, max: 1 },
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
unclipped: {
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe)
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
expect(world.obsAnnotations.rowIndex.keys()).toEqual(
|
||||
new Int32Array(matchingIndices)
|
||||
);
|
||||
expect(world.obsAnnotations.colIndex.keys()).toEqual(
|
||||
universe.obsAnnotations.colIndex.keys()
|
||||
);
|
||||
expect(world.obsLayout.rowIndex.keys()).toEqual(
|
||||
new Int32Array(matchingIndices)
|
||||
);
|
||||
expect(world.obsLayout.colIndex.keys()).toEqual(
|
||||
world.schema.layout.obs[0].dims
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createObsDimensionMap", () => {
|
||||
test("when universe eq world", () => {
|
||||
/*
|
||||
check for:
|
||||
- creates a dimension for all obsAnnotations, PLUS X/Y layout
|
||||
- check that dimension typing is sane
|
||||
*/
|
||||
|
||||
const { crossfilter } = defaultBigBang();
|
||||
const annotationNames = _.map(
|
||||
REST.schema.schema.annotations.obs.columns,
|
||||
c => c.name
|
||||
);
|
||||
const obsIndexColName = REST.schema.schema.annotations.obs.index;
|
||||
const schemaByObsName = _.keyBy(
|
||||
REST.schema.schema.annotations.obs.columns,
|
||||
"name"
|
||||
);
|
||||
expect(crossfilter).toBeDefined();
|
||||
annotationNames.forEach(name => {
|
||||
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
|
||||
if (name === obsIndexColName) {
|
||||
expect(dim).toBeUndefined();
|
||||
} else {
|
||||
const { type } = schemaByObsName[name];
|
||||
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||
expect(dim.dim).toBeInstanceOf(DimTypes.enum);
|
||||
} else {
|
||||
expect(dim.dim).toBeInstanceOf(DimTypes.scalar);
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(
|
||||
crossfilter.dimensions[layoutDimensionName("XY")].dim
|
||||
).toBeInstanceOf(DimTypes.spatial);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worldEqUniverse", () => {
|
||||
const { universe, world } = defaultBigBang();
|
||||
const result = World.worldEqUniverse(world, universe);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
import BitArray from "../../../src/util/typedCrossfilter/bitArray";
|
||||
|
||||
const defaultTestLength = 8;
|
||||
|
||||
describe("default select state", () => {
|
||||
test("newly created Bitarray should be deselected", () => {
|
||||
const ba = new BitArray(defaultTestLength);
|
||||
expect(ba).toBeDefined();
|
||||
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
|
||||
const dim = ba.allocDimension();
|
||||
expect(dim).toBeDefined();
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
|
||||
ba.freeDimension(dim);
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("select and deselect", () => {
|
||||
test("selectAll and deselectAll", () => {
|
||||
const ba = new BitArray(defaultTestLength);
|
||||
expect(ba).toBeDefined();
|
||||
const dim1 = ba.allocDimension();
|
||||
expect(dim1).toBeDefined();
|
||||
ba.selectAll(dim1);
|
||||
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(true);
|
||||
}
|
||||
|
||||
const dim2 = ba.allocDimension();
|
||||
expect(dim2).toBeDefined();
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
|
||||
ba.selectAll(dim2);
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(true);
|
||||
}
|
||||
|
||||
ba.deselectAll(dim1);
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
|
||||
ba.deselectAll(dim2);
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
|
||||
ba.selectAll(dim1);
|
||||
ba.selectAll(dim2);
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(true);
|
||||
}
|
||||
|
||||
ba.freeDimension(dim1);
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(true);
|
||||
}
|
||||
|
||||
ba.freeDimension(dim2);
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("selectOne and deselectOne", () => {
|
||||
const ba = new BitArray(defaultTestLength);
|
||||
expect(ba).toBeDefined();
|
||||
const dim = ba.allocDimension();
|
||||
expect(dim).toBeDefined();
|
||||
|
||||
ba.selectOne(dim, 0);
|
||||
expect(ba.isSelected(0)).toEqual(true);
|
||||
for (let i = 1; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
|
||||
ba.deselectOne(dim, 0);
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
|
||||
ba.selectOne(dim, 1);
|
||||
expect(ba.isSelected(1)).toEqual(true);
|
||||
expect(ba.isSelected(0)).toEqual(false);
|
||||
for (let i = 2; i < defaultTestLength; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(false);
|
||||
}
|
||||
|
||||
ba.selectAll(dim);
|
||||
ba.deselectOne(dim, defaultTestLength - 1);
|
||||
expect(ba.isSelected(defaultTestLength - 1)).toEqual(false);
|
||||
for (let i = 0; i < defaultTestLength - 1; i += 1) {
|
||||
expect(ba.isSelected(i)).toEqual(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionCount", () => {
|
||||
test("simple", () => {
|
||||
const ba = new BitArray(defaultTestLength);
|
||||
expect(ba).toBeDefined();
|
||||
const dim1 = ba.allocDimension();
|
||||
expect(dim1).toBeDefined();
|
||||
const dim2 = ba.allocDimension();
|
||||
expect(dim2).toBeDefined();
|
||||
|
||||
expect(ba.selectionCount()).toEqual(0);
|
||||
ba.selectAll(dim1);
|
||||
expect(ba.selectionCount()).toEqual(0);
|
||||
ba.selectAll(dim2);
|
||||
expect(ba.selectionCount()).toEqual(defaultTestLength);
|
||||
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
ba.deselectOne(dim1, i);
|
||||
expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1);
|
||||
expect(ba.selectionCount()).toEqual(ba.countAllOnes());
|
||||
}
|
||||
|
||||
ba.freeDimension(dim1);
|
||||
ba.freeDimension(dim2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fillBySelection", () => {
|
||||
test("sets values correctly", () => {
|
||||
const ba = new BitArray(defaultTestLength);
|
||||
expect(ba).toBeDefined();
|
||||
const dim = ba.allocDimension();
|
||||
expect(dim).toBeDefined();
|
||||
|
||||
const arr = new Int32Array(defaultTestLength);
|
||||
arr.fill(0);
|
||||
const truth = new Int32Array(defaultTestLength);
|
||||
truth.fill(0);
|
||||
|
||||
// initial state should be deselected
|
||||
ba.fillBySelection(arr, 1, 0);
|
||||
expect(arr).toEqual(expect.not.arrayContaining([1]));
|
||||
|
||||
// selectAll
|
||||
ba.selectAll(dim);
|
||||
ba.fillBySelection(arr, 1, 0);
|
||||
expect(arr).toEqual(expect.not.arrayContaining([0]));
|
||||
|
||||
// deselectOne
|
||||
ba.deselectOne(dim, 3);
|
||||
ba.fillBySelection(arr, 1, 0);
|
||||
truth.fill(1);
|
||||
truth[3] = 0;
|
||||
expect(arr).toEqual(truth);
|
||||
|
||||
// deselectAll
|
||||
ba.deselectAll(dim);
|
||||
ba.fillBySelection(arr, 1, 0);
|
||||
truth.fill(0);
|
||||
expect(arr).toEqual(truth);
|
||||
|
||||
// selectOne
|
||||
ba.selectOne(dim, 5);
|
||||
ba.fillBySelection(arr, 6, 1);
|
||||
truth.fill(1);
|
||||
truth[5] = 6;
|
||||
expect(arr).toEqual(truth);
|
||||
|
||||
// should be deselected after dimension disposal
|
||||
ba.freeDimension(dim);
|
||||
ba.fillBySelection(arr, 3, 9);
|
||||
truth.fill(9);
|
||||
expect(arr).toEqual(truth);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
import _ from "lodash";
|
||||
import { polygonContains } from "d3";
|
||||
|
||||
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||
|
||||
const someData = [
|
||||
{
|
||||
date: "2011-11-14T16:17:54Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001"],
|
||||
coords: [0, 0],
|
||||
nonFinite: 0.0
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:20:19Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001", "005"],
|
||||
coords: [0.4, 0.4],
|
||||
nonFinite: Number.NaN
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:28:54Z",
|
||||
quantity: 1,
|
||||
total: 300,
|
||||
tip: 200,
|
||||
type: "visa",
|
||||
productIDs: ["004", "005"],
|
||||
coords: [0.3, 0.1],
|
||||
nonFinite: Number.POSITIVE_INFINITY
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:30:43Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002"],
|
||||
coords: [0.392, 0.1],
|
||||
nonFinite: Number.NEGATIVE_INFINITY
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:48:46Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["005"],
|
||||
coords: [0.7, 0.0482],
|
||||
nonFinite: 1.0
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:53:41Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "004", "005"],
|
||||
coords: [0.9999, 1.0],
|
||||
nonFinite: Number.NaN
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:54:06Z",
|
||||
quantity: 1,
|
||||
total: 100,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["001", "002", "003", "004", "005"],
|
||||
coords: [0.384, 0.6938],
|
||||
nonFinite: 99.0
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:58:03Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001"],
|
||||
coords: [0.4822, 0.482],
|
||||
nonFinite: Number.NaN
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:07:21Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["004", "005"],
|
||||
coords: [0.2234, 0],
|
||||
nonFinite: Number.NaN
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:22:59Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002", "004", "005"],
|
||||
coords: [0.382, 0.38485],
|
||||
nonFinite: -1
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:25:45Z",
|
||||
quantity: 2,
|
||||
total: 200,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["002"],
|
||||
coords: [0.998, 0.8472],
|
||||
nonFinite: 0.0
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:29:52Z",
|
||||
quantity: 1,
|
||||
total: 200,
|
||||
tip: 100,
|
||||
type: "visa",
|
||||
productIDs: ["004"],
|
||||
coords: [0.8273, 0.3384],
|
||||
nonFinite: 0.0
|
||||
}
|
||||
];
|
||||
|
||||
let payments = null;
|
||||
beforeEach(() => {
|
||||
payments = new Crossfilter(someData);
|
||||
});
|
||||
|
||||
describe("ImmutableTypedCrossfilter", () => {
|
||||
test("create crossfilter", () => {
|
||||
expect(payments).toBeDefined();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
|
||||
const p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.select("quantity", { mode: "all" });
|
||||
expect(p).toBeDefined();
|
||||
expect(p.all()).toEqual(someData);
|
||||
expect(p.size()).toEqual(someData.length);
|
||||
expect(p.isElementSelected(0)).toBeTruthy();
|
||||
expect(p.countSelected()).toEqual(someData.length);
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("immutability", () => {
|
||||
/*
|
||||
the following should return a new crossfilter:
|
||||
- addDimension()
|
||||
- delDimension()
|
||||
- select
|
||||
*/
|
||||
const p2 = payments.addDimension(
|
||||
"quantity",
|
||||
"scalar",
|
||||
(i, data) => data[i].quantity,
|
||||
Int32Array
|
||||
);
|
||||
|
||||
expect(payments).not.toBe(p2);
|
||||
const p3 = p2.select("quantity", { mode: "all" });
|
||||
expect(p3).not.toBe(p2);
|
||||
|
||||
const p4 = p3.delDimension("quantity");
|
||||
expect(p4).not.toBe(p3);
|
||||
|
||||
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]]],
|
||||
[[[0, 0], [0, 0.5], [0.5, 0.5], [0.5, 0]]]
|
||||
])("within-polygon %p", polygon => {
|
||||
expect(
|
||||
p.select("coords", { mode: "within-polygon", polygon }).allSelected()
|
||||
).toEqual(_.filter(someData, d => polygonContains(polygon, d.coords)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-finite scalars", () => {
|
||||
let p;
|
||||
beforeEach(() => {
|
||||
p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.addDimension(
|
||||
"nonFinite",
|
||||
"scalar",
|
||||
(i, d) => d[i].nonFinite,
|
||||
Float32Array
|
||||
)
|
||||
.select("quantity", { mode: "all" });
|
||||
});
|
||||
|
||||
test("all or none", () => {
|
||||
expect(p.select("nonFinite", { mode: "all" }).countSelected()).toEqual(
|
||||
someData.length
|
||||
);
|
||||
expect(p.select("nonFinite", { mode: "none" }).countSelected()).toEqual(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test("exact", () => {
|
||||
expect(
|
||||
p.select("nonFinite", { mode: "exact", values: [0] }).countSelected()
|
||||
).toEqual(3);
|
||||
expect(
|
||||
p.select("nonFinite", { mode: "exact", values: [1] }).countSelected()
|
||||
).toEqual(1);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "exact",
|
||||
values: [Number.POSITIVE_INFINITY]
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(1);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "exact",
|
||||
values: [Number.NEGATIVE_INFINITY]
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(1);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", { mode: "exact", values: [Number.NaN] })
|
||||
.countSelected()
|
||||
).toEqual(4);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "exact",
|
||||
values: [Number.POSITIVE_INFINITY, 0, 1, 99]
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(6);
|
||||
});
|
||||
|
||||
test("range", () => {
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "range",
|
||||
lo: 0,
|
||||
hi: Number.POSITIVE_INFINITY
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(5);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "range",
|
||||
lo: 0,
|
||||
hi: Number.NaN
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(6);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "range",
|
||||
lo: Number.NEGATIVE_INFINITY,
|
||||
hi: Number.POSITIVE_INFINITY
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
// const PositiveIntervals = require("../../src/util/typedCrossfilter/positiveIntervals");
|
||||
import PositiveIntervals from "../../../src/util/typedCrossfilter/positiveIntervals";
|
||||
|
||||
describe("canonicalize", () => {
|
||||
test("empty", () => {
|
||||
expect(PositiveIntervals.canonicalize([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("simple, already correct", () => {
|
||||
expect(PositiveIntervals.canonicalize([[0, 1]])).toEqual([[0, 1]]);
|
||||
expect(PositiveIntervals.canonicalize([[0, 1], [2, 3]])).toEqual([
|
||||
[0, 1],
|
||||
[2, 3]
|
||||
]);
|
||||
});
|
||||
|
||||
test("non-canonical, need to be canonicalized", () => {
|
||||
expect(PositiveIntervals.canonicalize([[0, 1], [1, 2]])).toEqual([[0, 2]]);
|
||||
expect(PositiveIntervals.canonicalize([[1, 2], [2, 3]])).toEqual([[1, 3]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("union", () => {
|
||||
test("empty range", () => {
|
||||
expect(PositiveIntervals.union([], [])).toEqual([]);
|
||||
expect(PositiveIntervals.union([], [[1, 2]])).toEqual([[1, 2]]);
|
||||
expect(PositiveIntervals.union([], [[1, 2], [3, 4]])).toEqual([
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
]);
|
||||
expect(PositiveIntervals.union([[3, 4]], [])).toEqual([[3, 4]]);
|
||||
expect(PositiveIntervals.union([[1, 2], [3, 4]], [])).toEqual([
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
]);
|
||||
expect(PositiveIntervals.union([[3, 3]], [])).toEqual([[3, 3]]);
|
||||
expect(PositiveIntervals.union([], [[3, 3]])).toEqual([[3, 3]]);
|
||||
});
|
||||
|
||||
test("simple ranges", () => {
|
||||
expect(PositiveIntervals.union([[1, 2]], [[2, 3]])).toEqual([[1, 3]]);
|
||||
expect(PositiveIntervals.union([[2, 3]], [[1, 2]])).toEqual([[1, 3]]);
|
||||
expect(PositiveIntervals.union([[1, 2]], [[3, 4]])).toEqual([
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
]);
|
||||
expect(
|
||||
PositiveIntervals.union([[1, 2], [3, 4]], [[6, 7], [19, 40]])
|
||||
).toEqual([[1, 2], [3, 4], [6, 7], [19, 40]]);
|
||||
expect(PositiveIntervals.union([[1, 4]], [[1, 1], [3, 4]])).toEqual([
|
||||
[1, 4]
|
||||
]);
|
||||
expect(PositiveIntervals.union([[3, 3]], [[4, 4]])).toEqual([
|
||||
[3, 3],
|
||||
[4, 4]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("intersection", () => {
|
||||
test("empty range", () => {
|
||||
expect(PositiveIntervals.intersection([], [])).toEqual([]);
|
||||
expect(PositiveIntervals.intersection([], [[1, 2]])).toEqual([]);
|
||||
expect(PositiveIntervals.intersection([[1, 2]], [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("simple", () => {
|
||||
expect(PositiveIntervals.intersection([[1, 2]], [[2, 3]])).toEqual([]);
|
||||
expect(PositiveIntervals.intersection([[2, 3]], [[1, 2]])).toEqual([]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[1, 10]])).toEqual([
|
||||
[1, 10]
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[2, 8]])).toEqual([
|
||||
[2, 8]
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[2, 8]], [[1, 10]])).toEqual([
|
||||
[2, 8]
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[2, 12]])).toEqual([
|
||||
[2, 10]
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[2, 12]], [[1, 10]])).toEqual([
|
||||
[2, 10]
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[1, 8]])).toEqual([
|
||||
[1, 8]
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 8]], [[1, 10]])).toEqual([
|
||||
[1, 8]
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[1, 2], [6, 9]])).toEqual(
|
||||
[[1, 2], [6, 9]]
|
||||
);
|
||||
expect(PositiveIntervals.intersection([[0, 2638]], [[1363, 2638]])).toEqual(
|
||||
[[1363, 2638]]
|
||||
);
|
||||
expect(PositiveIntervals.intersection([[1, 2]], [[1, 2]])).toEqual([
|
||||
[1, 2]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("difference", () => {
|
||||
test("empty", () => {
|
||||
expect(PositiveIntervals.difference([], [])).toEqual([]);
|
||||
expect(PositiveIntervals.difference([], [[1, 10]])).toEqual([]);
|
||||
expect(PositiveIntervals.difference([[1, 10]], [])).toEqual([[1, 10]]);
|
||||
});
|
||||
|
||||
test("simple", () => {
|
||||
expect(PositiveIntervals.difference([[1, 2], [3, 4]], [])).toEqual([
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
]);
|
||||
expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[5, 10]])).toEqual([
|
||||
[1, 2],
|
||||
[3, 5]
|
||||
]);
|
||||
expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[0, 5]])).toEqual([
|
||||
[5, 10]
|
||||
]);
|
||||
expect(
|
||||
PositiveIntervals.difference([[0, 2638]], [[0, 1363], [2055, 2638]])
|
||||
).toEqual([[1363, 2055]]);
|
||||
expect(
|
||||
PositiveIntervals.difference([[0, 1363], [2055, 2638]], [[0, 2638]])
|
||||
).toEqual([]);
|
||||
expect(PositiveIntervals.difference([[0, 10]], [[0, 1]])).toEqual([
|
||||
[1, 10]
|
||||
]);
|
||||
expect(PositiveIntervals.difference([[0, 10]], [[1, 2]])).toEqual([
|
||||
[0, 1],
|
||||
[2, 10]
|
||||
]);
|
||||
expect(PositiveIntervals.difference([[0, 10]], [[9, 10]])).toEqual([
|
||||
[0, 9]
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
sortArray,
|
||||
sortIndex,
|
||||
lowerBound,
|
||||
upperBound,
|
||||
lowerBoundIndirect,
|
||||
upperBoundIndirect
|
||||
} from "../../../src/util/typedCrossfilter/sort";
|
||||
|
||||
/*
|
||||
Sort tests should keep in mind that there are separate code
|
||||
paths for:
|
||||
- small vs. large arrays (insertionsort only)
|
||||
- float-only typed arrays vs. other array types (non-finite handling)
|
||||
- indexed vs. direct sort
|
||||
*/
|
||||
|
||||
const pInf = Number.POSITIVE_INFINITY;
|
||||
const nInf = Number.NEGATIVE_INFINITY;
|
||||
|
||||
function fillRange(arr, start = 0) {
|
||||
const larr = arr;
|
||||
for (let i = 0, len = larr.length; i < len; i += 1) {
|
||||
larr[i] = i + start;
|
||||
}
|
||||
return larr;
|
||||
}
|
||||
|
||||
function fillRand(arr) {
|
||||
for (let i = 0, len = arr.length; i < len; i += 1) {
|
||||
arr[i] = Math.random();
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
describe("sortArray", () => {
|
||||
describe("JS vals", () => {
|
||||
[
|
||||
[true, false],
|
||||
["a", "b", "0", "1"],
|
||||
[0, "a", true, null, undefined, 3.1415],
|
||||
fillRand(new Array(1000)),
|
||||
["a", NaN, null, pInf]
|
||||
].map((val, idx) =>
|
||||
test(`JS vals ${idx}`, () => {
|
||||
expect(sortArray(val)).toMatchObject(val.sort());
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("finite numbers", () => {
|
||||
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
|
||||
test(Type.name, () => {
|
||||
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
|
||||
Type.from([0, 1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
|
||||
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
|
||||
Type.from([1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
|
||||
const source = fillRand(new Type(1000));
|
||||
expect(sortArray(Type.from(source))).toMatchObject(
|
||||
Type.from(source).sort()
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("non-finite numbers", () => {
|
||||
test("inifinity", () => {
|
||||
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
|
||||
new Float32Array([nInf, 0, 1, 2, pInf])
|
||||
);
|
||||
expect(
|
||||
sortArray(new Float32Array([pInf, nInf, pInf, nInf]))
|
||||
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf]));
|
||||
expect(
|
||||
sortArray(new Float32Array([pInf, nInf, pInf, nInf, pInf]))
|
||||
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf, pInf]));
|
||||
expect(
|
||||
sortArray(
|
||||
new Float32Array(100).fill(Infinity, 0, 50).fill(-Infinity, 50, 100)
|
||||
)
|
||||
).toMatchObject(
|
||||
new Float32Array(100).fill(-Infinity, 0, 50).fill(Infinity, 50, 100)
|
||||
);
|
||||
});
|
||||
test("NaN", () => {
|
||||
expect(sortArray(new Float64Array([NaN, 2, 1, 0]))).toMatchObject(
|
||||
new Float64Array([0, 1, 2, NaN])
|
||||
);
|
||||
expect(sortArray(new Float32Array([NaN, 2, 1, 0]))).toMatchObject(
|
||||
new Float32Array([0, 1, 2, NaN])
|
||||
);
|
||||
expect(sortArray(new Float32Array([NaN, 2, NaN, 1, 0]))).toMatchObject(
|
||||
new Float32Array([0, 1, 2, NaN, NaN])
|
||||
);
|
||||
expect(sortArray(new Float32Array([NaN, 2, 1, NaN, 0]))).toMatchObject(
|
||||
new Float32Array([0, 1, 2, NaN, NaN])
|
||||
);
|
||||
expect(
|
||||
sortArray(fillRange(new Float32Array(100)).fill(NaN, 0, 10))
|
||||
).toMatchObject(fillRange(new Float32Array(100), 10).fill(NaN, 90, 100));
|
||||
});
|
||||
|
||||
test("mixed numbers", () => {
|
||||
expect(
|
||||
sortArray(new Float32Array([NaN, pInf, nInf, NaN, NaN]))
|
||||
).toMatchObject(new Float32Array([nInf, pInf, NaN, NaN, NaN]));
|
||||
expect(
|
||||
sortArray(new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]))
|
||||
).toMatchObject(new Float32Array([nInf, 1, 2, pInf, NaN, NaN, NaN]));
|
||||
expect(
|
||||
sortArray(new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]))
|
||||
).toMatchObject(new Float32Array([nInf, 0, 1, 2, pInf, NaN, NaN]));
|
||||
expect(
|
||||
sortArray(
|
||||
fillRange(new Float32Array(100))
|
||||
.fill(NaN, 0, 10)
|
||||
.fill(Infinity, 10, 20)
|
||||
)
|
||||
).toMatchObject(
|
||||
fillRange(new Float32Array(100), 20)
|
||||
.fill(Infinity, 80, 90)
|
||||
.fill(NaN, 90, 100)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortIndex", () => {
|
||||
describe("finite numbers", () => {
|
||||
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
|
||||
test(Type.name, () => {
|
||||
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
|
||||
const index1 = fillRange(new Uint32Array(source1.length));
|
||||
expect(sortIndex(index1, source1)).toMatchObject(
|
||||
index1.sort((a, b) => source1[a] - source1[b])
|
||||
);
|
||||
|
||||
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
|
||||
const index2 = fillRange(new Uint32Array(source2.length));
|
||||
expect(sortIndex(index2, source2)).toMatchObject(
|
||||
index2.sort((a, b) => source1[a] - source1[b])
|
||||
);
|
||||
|
||||
const source3 = fillRand(new Type(1000));
|
||||
const index3 = fillRange(new Uint32Array(source3.length));
|
||||
expect(sortIndex(index3, source3)).toMatchObject(
|
||||
index3.sort((a, b) => source1[a] - source1[b])
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("non-finite numbers", () => {
|
||||
test("mixed numbers", () => {
|
||||
const source1 = new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]);
|
||||
const index1 = fillRange(new Uint32Array(source1.length));
|
||||
expect(sortIndex(index1, source1)).toMatchObject(
|
||||
new Uint32Array([2, 4, 6, 1, 0, 3, 5])
|
||||
);
|
||||
|
||||
const source2 = new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]);
|
||||
const index2 = fillRange(new Uint32Array(source2.length));
|
||||
expect(sortIndex(index2, source2)).toMatchObject(
|
||||
new Uint32Array([2, 3, 4, 6, 1, 0, 5])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("lowerBound", () => {
|
||||
test("non-float path", () => {
|
||||
expect(lowerBound([], 0, 0, 0)).toEqual(0);
|
||||
|
||||
expect(lowerBound([0, 1, 2, 3], -1, 0, 4)).toEqual(0);
|
||||
expect(lowerBound([0, 1, 2, 3], 0, 0, 4)).toEqual(0);
|
||||
expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1);
|
||||
expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3);
|
||||
expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4);
|
||||
expect(lowerBound([0, 1, 2, 3], 4, 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
sliceByIndex,
|
||||
makeSortIndex
|
||||
} from "../../../src/util/typedCrossfilter/util";
|
||||
import { rangeFill as fillRange } from "../../../src/util/range";
|
||||
|
||||
describe("fillRange", () => {
|
||||
test("Array", () => {
|
||||
expect(fillRange(new Array(6))).toMatchObject([0, 1, 2, 3, 4, 5]);
|
||||
expect(fillRange(new Array(4), 1)).toMatchObject([1, 2, 3, 4]);
|
||||
expect(fillRange([])).toMatchObject([]);
|
||||
});
|
||||
|
||||
test("Uint32Array", () => {
|
||||
expect(fillRange(new Uint32Array(6))).toMatchObject(
|
||||
new Uint32Array([0, 1, 2, 3, 4, 5])
|
||||
);
|
||||
expect(fillRange(new Array(4), 1)).toMatchObject(
|
||||
new Uint32Array([1, 2, 3, 4])
|
||||
);
|
||||
expect(fillRange([])).toMatchObject(new Uint32Array([]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sliceByIndex", () => {
|
||||
test("Array", () => {
|
||||
expect(sliceByIndex([0, 1, 2, 3, 4], [0, 1, 2])).toMatchObject([0, 1, 2]);
|
||||
expect(sliceByIndex([0, 1, 2, 3, 4], [2, 1, 0])).toMatchObject([2, 1, 0]);
|
||||
expect(sliceByIndex([0, 1, 2, 3, 4], [])).toMatchObject([]);
|
||||
expect(sliceByIndex([], [])).toMatchObject([]);
|
||||
});
|
||||
|
||||
test("Uint32Array", () => {
|
||||
expect(
|
||||
sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([0, 1, 2]))
|
||||
).toMatchObject([0, 1, 2]);
|
||||
expect(
|
||||
sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([2, 1, 0]))
|
||||
).toMatchObject([2, 1, 0]);
|
||||
expect(sliceByIndex([0, 1, 2, 3, 4], new Uint32Array([]))).toMatchObject(
|
||||
[]
|
||||
);
|
||||
expect(sliceByIndex([], new Uint32Array([]))).toMatchObject([]);
|
||||
|
||||
expect(
|
||||
sliceByIndex(new Uint32Array([0, 1, 2, 3, 4]), new Uint32Array([0, 1, 2]))
|
||||
).toMatchObject(new Uint32Array([0, 1, 2]));
|
||||
expect(
|
||||
sliceByIndex(new Uint32Array([0, 1, 2, 3, 4]), new Uint32Array([2, 1, 0]))
|
||||
).toMatchObject(new Uint32Array([2, 1, 0]));
|
||||
expect(
|
||||
sliceByIndex(
|
||||
new Uint32Array([0, 1, 2, 3, 4]),
|
||||
new Uint32Array(new Uint32Array([]))
|
||||
)
|
||||
).toMatchObject(new Uint32Array([]));
|
||||
expect(
|
||||
sliceByIndex(new Uint32Array([]), new Uint32Array([]))
|
||||
).toMatchObject(new Uint32Array([]));
|
||||
});
|
||||
|
||||
test("Float32Array", () => {
|
||||
expect(
|
||||
sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [0, 1, 2])
|
||||
).toMatchObject(new Float32Array([0, 1, 2]));
|
||||
expect(
|
||||
sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [2, 1, 0])
|
||||
).toMatchObject(new Float32Array([2, 1, 0]));
|
||||
expect(sliceByIndex(new Float32Array([0, 1, 2, 3, 4]), [])).toMatchObject(
|
||||
new Float32Array([])
|
||||
);
|
||||
expect(sliceByIndex(new Float32Array([]), [])).toMatchObject(
|
||||
new Float32Array([])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeSortIndex", () => {
|
||||
test("Array", () => {
|
||||
expect(makeSortIndex([3, 2, 1, 0])).toMatchObject(
|
||||
new Uint32Array([3, 2, 1, 0])
|
||||
);
|
||||
expect(makeSortIndex([3, 2, 1, 0, 4])).toMatchObject(
|
||||
new Uint32Array([3, 2, 1, 0, 4])
|
||||
);
|
||||
expect(makeSortIndex([])).toMatchObject(new Uint32Array([]));
|
||||
});
|
||||
|
||||
test("Float32Array", () => {
|
||||
expect(makeSortIndex(new Float32Array([3, 2, 1, 0]))).toMatchObject(
|
||||
new Uint32Array([3, 2, 1, 0])
|
||||
);
|
||||
expect(makeSortIndex(new Float32Array([3, 2, 1, 0, 4]))).toMatchObject(
|
||||
new Uint32Array([3, 2, 1, 0, 4])
|
||||
);
|
||||
expect(makeSortIndex(new Float32Array([]))).toMatchObject(
|
||||
new Uint32Array([])
|
||||
);
|
||||
});
|
||||
|
||||
test("Int32Array", () => {
|
||||
expect(makeSortIndex(new Int32Array([3, 2, 1, 0]))).toMatchObject(
|
||||
new Uint32Array([3, 2, 1, 0])
|
||||
);
|
||||
expect(makeSortIndex(new Int32Array([3, 2, 1, 0, 4]))).toMatchObject(
|
||||
new Uint32Array([3, 2, 1, 0, 4])
|
||||
);
|
||||
expect(makeSortIndex(new Int32Array([]))).toMatchObject(
|
||||
new Uint32Array([])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
babelrc: false,
|
||||
cacheDirectory: true,
|
||||
presets: [
|
||||
["modern-browsers", { loose: true, modules: false }],
|
||||
"@babel/preset-react"
|
||||
],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
["@babel/plugin-proposal-decorators", { legacy: true }],
|
||||
["@babel/plugin-proposal-class-properties", { loose: true }],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
babelrc: false,
|
||||
presets: [
|
||||
["modern-browsers", { loose: true, modules: false }],
|
||||
"@babel/preset-react"
|
||||
],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
["@babel/plugin-proposal-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-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: "babel-eslint",
|
||||
extends: ["airbnb", "prettier", "prettier/react"],
|
||||
env: { browser: true, commonjs: true, es6: true },
|
||||
globals: { expect: true },
|
||||
parserOptions: {
|
||||
ecmaVersion: 2017,
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
generators: true
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"no-magic-numbers": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"func-style": "off",
|
||||
"arrow-parens": "off",
|
||||
"no-use-before-define": "off",
|
||||
"react/jsx-filename-extension": "off",
|
||||
"comma-dangle": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
quotes: ["error", "double"],
|
||||
"implicit-arrow-linebreak": "off",
|
||||
"operator-linebreak": [
|
||||
"error",
|
||||
"after",
|
||||
{ overrides: { "?": "before", ":": "before" } }
|
||||
],
|
||||
"no-console": "off",
|
||||
"spaced-comment": ["error", "always", { exceptions: ["*"] }],
|
||||
"no-param-reassign": "off",
|
||||
"object-curly-newline": ["error", { consistent: true }],
|
||||
"react/prop-types": [0],
|
||||
"space-before-function-paren": "off",
|
||||
"function-paren-newline": "off",
|
||||
"prefer-destructuring": ["error", { object: true, array: false }]
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/*.test.js"],
|
||||
env: {
|
||||
jest: true // now **/*.test.js files' env has both es6 *and* jest
|
||||
},
|
||||
// Can't extend in overrides: https://github.com/eslint/eslint/issues/8813
|
||||
// "extends": ["plugin:jest/recommended"]
|
||||
plugins: ["jest"],
|
||||
rules: {
|
||||
"jest/no-disabled-tests": "warn",
|
||||
"jest/no-focused-tests": "error",
|
||||
"jest/no-identical-title": "error",
|
||||
"jest/prefer-to-have-length": "warn",
|
||||
"jest/valid-expect": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
+38
-28
@@ -1,41 +1,62 @@
|
||||
// jshint esversion: 6
|
||||
const path = require("path");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const webpack = require("webpack");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
|
||||
const src = path.resolve("src");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
|
||||
const babelOptions = require("../babel/babel.dev");
|
||||
|
||||
module.exports = {
|
||||
mode: "development",
|
||||
devtool: "eval",
|
||||
entry: [
|
||||
"webpack-hot-middleware/client?quiet=true&noInfo=true",
|
||||
require.resolve("react-hot-loader/patch"),
|
||||
require.resolve("../polyfills/polyfills"),
|
||||
path.join(src, "index")
|
||||
],
|
||||
entry: ["./src/index"],
|
||||
output: {
|
||||
path: path.resolve("build"),
|
||||
pathinfo: true,
|
||||
filename: "static/js/bundle.js",
|
||||
publicPath: "/"
|
||||
},
|
||||
resolve: { extensions: [".js", ".json"] },
|
||||
module: {
|
||||
loaders: [
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
include: src,
|
||||
loader: "babel-loader",
|
||||
query: require("../babel/babel.dev")
|
||||
options: babelOptions
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
include: [src, nodeModules],
|
||||
loader:
|
||||
"style-loader!css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader"
|
||||
include: src,
|
||||
exclude: [path.resolve(src, "index.css")],
|
||||
loader: [
|
||||
{
|
||||
loader: "style-loader"
|
||||
},
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: {
|
||||
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /index\.css$/,
|
||||
include: [path.resolve(src, "index.css")],
|
||||
loader: [
|
||||
{
|
||||
loader: "style-loader"
|
||||
},
|
||||
{
|
||||
loader: "css-loader"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{ test: /\.json$/, include: [src, nodeModules], loader: "json-loader" },
|
||||
{
|
||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
||||
@@ -57,20 +78,9 @@ module.exports = {
|
||||
template: path.resolve("index.html"),
|
||||
favicon: path.resolve("favicon.png")
|
||||
}),
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
options: {
|
||||
eslint: {
|
||||
configFile: path.resolve("./configuration/eslint/eslint.js"),
|
||||
useEslintrc: false
|
||||
},
|
||||
postcss() {
|
||||
return [autoprefixer];
|
||||
}
|
||||
}
|
||||
}),
|
||||
new webpack.DefinePlugin({ "process.env.NODE_ENV": '"development"' }),
|
||||
// Note: only CSS is currently hot reloaded
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoEmitOnErrorsPlugin()
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
__REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })"
|
||||
})
|
||||
]
|
||||
};
|
||||
+47
-33
@@ -1,46 +1,65 @@
|
||||
// jshint esversion: 6
|
||||
const path = require("path");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const webpack = require("webpack");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const ExtractTextPlugin = require("extract-text-webpack-plugin");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
|
||||
const MinifyPlugin = require("babel-minify-webpack-plugin");
|
||||
|
||||
const src = path.resolve("src");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
|
||||
const publicPath = "/";
|
||||
const babelOptions = require("../babel/babel.prod");
|
||||
|
||||
const publicPath = "";
|
||||
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
bail: true,
|
||||
cache: false,
|
||||
devtool: "cheap-source-map",
|
||||
entry: [require.resolve("../polyfills/polyfills"), path.join(src, "index")],
|
||||
entry: ["./src/index.js"],
|
||||
output: {
|
||||
path: path.resolve("build"),
|
||||
filename: "static/js/[name].[chunkhash:8].js",
|
||||
chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
|
||||
publicPath
|
||||
},
|
||||
resolve: { extensions: [".js", ".json"] },
|
||||
module: {
|
||||
loaders: [
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
include: src,
|
||||
loader: "babel-loader",
|
||||
query: require("../babel/babel.prod")
|
||||
options: babelOptions
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
include: [src, nodeModules],
|
||||
loader: ExtractTextPlugin.extract({
|
||||
fallbackLoader: "style-loader",
|
||||
loader:
|
||||
"css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]-autoprefixer!postcss-loader"
|
||||
})
|
||||
include: src,
|
||||
exclude: [path.resolve(src, "index.css")],
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: {
|
||||
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||
},
|
||||
importLoaders: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /index\.css$/,
|
||||
include: [path.resolve(src, "index.css")],
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
importLoaders: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
@@ -52,7 +71,10 @@ module.exports = {
|
||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
||||
include: [src, nodeModules],
|
||||
loader: "file-loader",
|
||||
query: { name: "static/media/[name].[hash:8].[ext]" }
|
||||
options: {
|
||||
name: "[name].[hash:8].[ext]",
|
||||
outputPath: "static/media/"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(mp4|webm)(\?.*)?$/,
|
||||
@@ -83,24 +105,16 @@ module.exports = {
|
||||
}
|
||||
}),
|
||||
new HtmlWebpackInlineSourcePlugin(),
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
options: {
|
||||
eslint: {
|
||||
configFile: path.resolve("./configuration/eslint/eslint.js"),
|
||||
useEslintrc: false
|
||||
},
|
||||
postcss() {
|
||||
return [autoprefixer];
|
||||
}
|
||||
}
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "static/css/[name].[contenthash:8].css"
|
||||
}),
|
||||
new webpack.DefinePlugin({ "process.env.NODE_ENV": '"production"' }),
|
||||
new webpack.optimize.OccurrenceOrderPlugin(),
|
||||
new MinifyPlugin(),
|
||||
new ExtractTextPlugin("static/css/[name].[contenthash:8].css"),
|
||||
new SWPrecacheWebpackPlugin({
|
||||
cacheId: "cellxgene",
|
||||
filename: "service-worker.js"
|
||||
})
|
||||
]
|
||||
],
|
||||
performance: {
|
||||
maxEntrypointSize: 2000000,
|
||||
maxAssetSize: 2000000
|
||||
}
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 312 B |
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cellx</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:400,400i,700" rel="stylesheet">
|
||||
<title>cell×gene</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
|
||||
<style>
|
||||
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input {
|
||||
font-family: 'Roboto','Helvetica Neue','Helvetica','Arial',sans-serif;
|
||||
font-size: 16px;
|
||||
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;
|
||||
@@ -24,7 +24,6 @@
|
||||
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>, please <strong>enable JS</strong> to make this app work.</noscript>
|
||||
|
||||
<div id="root"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.js"></script>
|
||||
<script src="//d3js.org/d3-scale-chromatic.v0.3.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>cell×gene</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
|
||||
<style>
|
||||
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
|
||||
font-family: 'Roboto Condensed', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
window.CELLXGENE = {};
|
||||
window.CELLXGENE.API = {
|
||||
prefix: window.location.href + "api/",
|
||||
version: "v0.2/"
|
||||
};
|
||||
</script>
|
||||
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>,
|
||||
please <strong>enable JS</strong> to make this app work.
|
||||
</noscript>
|
||||
|
||||
<div id="root"></div>
|
||||
{% for script in SCRIPTS %}
|
||||
<script type="text/javascript" src="{{script | safe}}"></script>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
Generated
+15295
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"name": "cellxgene",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT",
|
||||
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
|
||||
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
||||
"scripts": {
|
||||
"backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ",
|
||||
"backend-dev-anno": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-annotations ",
|
||||
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
|
||||
"clean": "rimraf build",
|
||||
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
|
||||
"e2e": "node node_modules/jest/bin/jest.js --verbose false --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
|
||||
"lint": "eslint src",
|
||||
"smoke-test": "start-server-and-test start-server-for-test :5000 e2e",
|
||||
"start": "node server/development.js",
|
||||
"start-server-for-test": "cellxgene launch -p 5000 ../example-dataset/pbmc3k.h5ad",
|
||||
"test": "node node_modules/jest/bin/jest.js",
|
||||
"unit-test": "node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"engines": {
|
||||
"npm": ">=3.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "./configuration/eslint/eslint.js"
|
||||
},
|
||||
"eslintIgnore": [
|
||||
"src/util/stateManager/matrix_generated.js"
|
||||
],
|
||||
"resolutions": {
|
||||
"eslint-scope": "3.7.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^3.20.0",
|
||||
"@blueprintjs/icons": "^3.12.0",
|
||||
"@blueprintjs/select": "^3.11.2",
|
||||
"d3": "^4.10.0",
|
||||
"d3-scale-chromatic": "^1.5.0",
|
||||
"flatbuffers": "^1.11.0",
|
||||
"fuzzysort": "^1.1.4",
|
||||
"gl-mat4": "^1.1.4",
|
||||
"gl-matrix": "^3.1.0",
|
||||
"gl-vec3": "^1.1.3",
|
||||
"is-number": "^7.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"memoize-one": "^5.1.1",
|
||||
"react": "^16.11.0",
|
||||
"react-autocomplete": "^1.7.2",
|
||||
"react-dom": "^16.11.0",
|
||||
"react-flip-toolkit": "7.0.6",
|
||||
"react-helmet": "^5.2.1",
|
||||
"react-icons": "^3.8.0",
|
||||
"react-redux": "^7.1.3",
|
||||
"redux": "^4.0.4",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"regl": "^1.3.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.7.2",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.0",
|
||||
"@babel/plugin-proposal-decorators": "^7.7.0",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.5.2",
|
||||
"@babel/plugin-proposal-function-bind": "^7.2.0",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.6.0",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.6.3",
|
||||
"@babel/plugin-transform-runtime": "^7.6.2",
|
||||
"@babel/preset-env": "^7.7.1",
|
||||
"@babel/preset-react": "^7.7.0",
|
||||
"@babel/register": "^7.7.0",
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-preset-modern-browsers": "^14.0.0",
|
||||
"chalk": "^3.0.0",
|
||||
"connect-history-api-fallback": "^1.6.0",
|
||||
"copy-webpack-plugin": "^5.0.5",
|
||||
"css-loader": "^3.2.0",
|
||||
"eslint": "^6.6.0",
|
||||
"eslint-config-airbnb": "^18.0.1",
|
||||
"eslint-config-prettier": "^6.5.0",
|
||||
"eslint-loader": "^3.0.2",
|
||||
"eslint-plugin-filenames": "^1.3.2",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-jest": "^23.0.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-react": "^7.16.0",
|
||||
"express": "^4.17.1",
|
||||
"file-loader": "^4.2.0",
|
||||
"html-webpack-inline-source-plugin": "0.0.10",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-puppeteer": "^4.3.0",
|
||||
"json-loader": "^0.5.4",
|
||||
"mini-css-extract-plugin": "^0.8.0",
|
||||
"puppeteer": "^2.0.0",
|
||||
"rimraf": "^3.0.0",
|
||||
"serve-favicon": "^2.3.0",
|
||||
"start-server-and-test": "^1.10.6",
|
||||
"style-loader": "^1.0.0",
|
||||
"sw-precache-webpack-plugin": "^0.11.5",
|
||||
"url-loader": "^2.2.0",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.3.10",
|
||||
"webpack-dev-middleware": "^3.7.2"
|
||||
},
|
||||
"jest": {
|
||||
"testMatch": [
|
||||
"**/__tests__/**/?(*.)(spec|test).js?(x)"
|
||||
],
|
||||
"testURL": "http://localhost/",
|
||||
"setupFiles": [
|
||||
"./__tests__/setupMissingGlobals.js"
|
||||
]
|
||||
},
|
||||
"babel": {
|
||||
"env": {
|
||||
"test": {
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
[
|
||||
"@babel/plugin-proposal-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-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,11 @@ app.use(historyApiFallback({ verbose: false }));
|
||||
|
||||
app.use(
|
||||
require("webpack-dev-middleware")(compiler, {
|
||||
noInfo: true,
|
||||
logLevel: "warn",
|
||||
publicPath: config.output.publicPath
|
||||
})
|
||||
);
|
||||
|
||||
app.use(require("webpack-hot-middleware")(compiler));
|
||||
|
||||
app.use(favicon("./favicon.png"));
|
||||
|
||||
app.get("*", (req, res) => {
|
||||
@@ -0,0 +1,402 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import * as globals from "../globals";
|
||||
import { Universe, MatrixFBS } from "../util/stateManager";
|
||||
import {
|
||||
catchErrorsWrap,
|
||||
doJsonRequest,
|
||||
doBinaryRequest,
|
||||
dispatchNetworkErrorMessageToUser
|
||||
} from "../util/actionHelpers";
|
||||
|
||||
/*
|
||||
Bootstrap application with the initial data loading.
|
||||
* /config - application configuration
|
||||
* /schema - schema of dataframe
|
||||
* /annotations - all metadata annotation
|
||||
* /layout - all default layout
|
||||
*/
|
||||
const doInitialDataLoad = () =>
|
||||
catchErrorsWrap(async dispatch => {
|
||||
dispatch({ type: "initial data load start" });
|
||||
|
||||
try {
|
||||
/*
|
||||
Step 1 - config & schema, all JSON
|
||||
*/
|
||||
const requestJson = ["config", "schema"]
|
||||
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
|
||||
.map(url => doJsonRequest(url));
|
||||
const stepOneResults = await Promise.all(requestJson);
|
||||
/* set config defaults */
|
||||
const config = { ...globals.configDefaults, ...stepOneResults[0].config };
|
||||
const schema = stepOneResults[1];
|
||||
|
||||
/*
|
||||
Step 2 - dataframes, all binary. NOTE: uses results of step 1.
|
||||
*/
|
||||
/* only load names for var annotations, if possible*/
|
||||
const varIndexName = schema?.schema?.annotations?.var?.index;
|
||||
const varAnnotationsQuery = varIndexName
|
||||
? `?annotation-name=${encodeURIComponent(varIndexName)}`
|
||||
: "";
|
||||
const varAnnotationsURL = `annotations/var${varAnnotationsQuery}`;
|
||||
const requestBinary = ["annotations/obs", varAnnotationsURL, "layout/obs"]
|
||||
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
|
||||
.map(url => doBinaryRequest(url));
|
||||
const stepTwoResults = await Promise.all(requestBinary);
|
||||
const [obsAnno, varAnno, obsLayout] = [...stepTwoResults];
|
||||
|
||||
const universe = Universe.createUniverseFromResponse(
|
||||
config,
|
||||
schema,
|
||||
obsAnno,
|
||||
varAnno,
|
||||
obsLayout
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "configuration load complete",
|
||||
config
|
||||
});
|
||||
dispatch({
|
||||
type: "initial data load complete (universe exists)",
|
||||
universe
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({ type: "initial data load error", error });
|
||||
}
|
||||
}, true);
|
||||
|
||||
/*
|
||||
Set the view (world) to current selection. Placeholder for an async action
|
||||
which also does re-layout.
|
||||
*/
|
||||
const regraph = () => (dispatch, getState) => {
|
||||
const { universe, world, crossfilter } = getState();
|
||||
dispatch({
|
||||
type: "set World to current selection",
|
||||
universe,
|
||||
world,
|
||||
crossfilter
|
||||
});
|
||||
};
|
||||
|
||||
// Throws
|
||||
const dispatchExpressionErrors = (dispatch, res) => {
|
||||
const msg = `Unexpected HTTP response while fetching expression data ${res.status}, ${res.statusText}`;
|
||||
dispatchNetworkErrorMessageToUser(msg);
|
||||
throw new Error(msg);
|
||||
};
|
||||
|
||||
/*
|
||||
Fetch expression vectors for each gene in genes. This is NOT an action
|
||||
function, but rather a helper to be called from an action helper that
|
||||
needs expression data.
|
||||
|
||||
Transparently utilizes cached data if it is already present.
|
||||
*/
|
||||
async function _doRequestExpressionData(dispatch, getState, genes) {
|
||||
const state = getState();
|
||||
const { universe } = state;
|
||||
const varIndexName = universe.schema.annotations.var.index;
|
||||
|
||||
/* helper for this function only */
|
||||
const fetchData = async geneNames => {
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}data/var`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
filter: {
|
||||
var: {
|
||||
annotation_value: [{ name: varIndexName, values: geneNames }]
|
||||
}
|
||||
}
|
||||
}),
|
||||
headers: new Headers({
|
||||
accept: "application/octet-stream",
|
||||
"Content-Type": "application/json"
|
||||
}),
|
||||
credentials: "include"
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
res.headers.get("Content-Type") !== "application/octet-stream"
|
||||
) {
|
||||
// WILL throw
|
||||
return dispatchExpressionErrors(dispatch, res);
|
||||
}
|
||||
|
||||
const data = await res.arrayBuffer();
|
||||
return Universe.convertDataFBStoObject(universe, data);
|
||||
};
|
||||
|
||||
/* preload data already in cache */
|
||||
let expressionData = _.transform(
|
||||
genes,
|
||||
(expData, g) => {
|
||||
const data = universe.varData.col(g);
|
||||
if (data) {
|
||||
expData[g] = data.asArray();
|
||||
}
|
||||
},
|
||||
{}
|
||||
); // --> { gene: data }
|
||||
/* make a list of genes for which we do not have data */
|
||||
const genesToFetch = _.filter(genes, g => expressionData[g] === undefined);
|
||||
|
||||
dispatch({ type: "expression load start" });
|
||||
|
||||
/* Fetch data for any genes not in cache */
|
||||
if (genesToFetch.length) {
|
||||
try {
|
||||
const newExpressionData = await fetchData(genesToFetch);
|
||||
expressionData = {
|
||||
...expressionData,
|
||||
...newExpressionData
|
||||
};
|
||||
} catch (error) {
|
||||
dispatch({ type: "expression load error", error });
|
||||
throw error; // rethrow
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: "expression load success", expressionData });
|
||||
return expressionData;
|
||||
}
|
||||
|
||||
function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({ type: "get single gene expression for coloring started" });
|
||||
try {
|
||||
await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState();
|
||||
dispatch({
|
||||
type: "color by expression",
|
||||
gene,
|
||||
data: {
|
||||
[gene]: world.varData.col(gene).asArray()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "get single gene expression for coloring error",
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const requestUserDefinedGene = gene => async (dispatch, getState) => {
|
||||
dispatch({ type: "request user defined gene started" });
|
||||
try {
|
||||
await await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState();
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
type: "request user defined gene success",
|
||||
data: {
|
||||
genes: [gene],
|
||||
expression: world.varData.col(gene).asArray()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return dispatch({
|
||||
type: "request user defined gene error",
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchDiffExpErrors = (dispatch, response) => {
|
||||
switch (response.status) {
|
||||
case 403:
|
||||
dispatchNetworkErrorMessageToUser(
|
||||
"Too many cells selected for differential experesion calculation - please make a smaller selection."
|
||||
);
|
||||
break;
|
||||
case 501:
|
||||
dispatchNetworkErrorMessageToUser(
|
||||
"Differential expression is not implemented."
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
const msg = `Unexpected differential expression HTTP response ${response.status}, ${response.statusText}`;
|
||||
dispatchNetworkErrorMessageToUser(msg);
|
||||
dispatch({
|
||||
type: "request differential expression error",
|
||||
error: new Error(msg)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
dispatch({ type: "request differential expression started" });
|
||||
try {
|
||||
/*
|
||||
Steps:
|
||||
1. get the most differentially expressed genes
|
||||
2. get expression data for each
|
||||
*/
|
||||
const state = getState();
|
||||
const { universe } = state;
|
||||
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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
mode: "topN",
|
||||
count: num_genes,
|
||||
set1: { filter: { obs: { index: set1 } } },
|
||||
set2: { filter: { obs: { index: set2 } } }
|
||||
}),
|
||||
credentials: "include"
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
|
||||
return dispatchDiffExpErrors(dispatch, res);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// result is [ [varIdx, ...], ... ]
|
||||
const topNGenes = _.map(data, r =>
|
||||
universe.varAnnotations.at(r[0], varIndexName)
|
||||
);
|
||||
|
||||
/*
|
||||
Kick off secondary action to fetch all of the expression data for the
|
||||
topN expressed genes.
|
||||
*/
|
||||
await _doRequestExpressionData(dispatch, getState, topNGenes);
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
type: "request differential expression success",
|
||||
data
|
||||
});
|
||||
} catch (error) {
|
||||
return dispatch({
|
||||
type: "request differential expression error",
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetInterface = () => (dispatch, getState) => {
|
||||
const { universe } = getState();
|
||||
|
||||
dispatch({
|
||||
type: "user reset start"
|
||||
});
|
||||
dispatch({
|
||||
type: "clear all user defined genes"
|
||||
});
|
||||
dispatch({
|
||||
type: "clear differential expression"
|
||||
});
|
||||
dispatch({
|
||||
type: "reset colorscale"
|
||||
});
|
||||
dispatch({
|
||||
type: "clear scatterplot"
|
||||
});
|
||||
dispatch({
|
||||
type: "reset World to eq Universe",
|
||||
universe
|
||||
});
|
||||
dispatch({
|
||||
type: "increment graph render counter"
|
||||
});
|
||||
dispatch({
|
||||
type: "user reset end"
|
||||
});
|
||||
};
|
||||
|
||||
const saveObsAnnotations = () => async (dispatch, getState) => {
|
||||
const { universe, annotations } = getState();
|
||||
const { obsAnnotations, schema } = universe;
|
||||
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
|
||||
|
||||
dispatch({
|
||||
type: "writable obs annotations - save started"
|
||||
});
|
||||
|
||||
const writableAnnotations = schema.annotations.obs.columns
|
||||
.filter(s => s.writable)
|
||||
.map(s => s.name);
|
||||
const df = obsAnnotations.subset(null, writableAnnotations);
|
||||
const matrix = MatrixFBS.encodeMatrixFBS(df);
|
||||
try {
|
||||
const queryString =
|
||||
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
||||
? `?annotation-collection-name=${encodeURIComponent(
|
||||
dataCollectionName
|
||||
)}`
|
||||
: "";
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: matrix,
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/octet-stream"
|
||||
}),
|
||||
credentials: "include"
|
||||
}
|
||||
);
|
||||
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,
|
||||
requestDifferentialExpression,
|
||||
requestUserDefinedGene,
|
||||
doInitialDataLoad,
|
||||
saveObsAnnotations
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import Helmet from "react-helmet";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import Container from "./framework/container";
|
||||
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 => ({
|
||||
loading: state.controls.loading,
|
||||
error: state.controls.error,
|
||||
graphRenderCounter: state.controls.graphRenderCounter
|
||||
}))
|
||||
class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
/* listen for url changes, fire one when we start the app up */
|
||||
window.addEventListener("popstate", this._onURLChanged);
|
||||
this._onURLChanged();
|
||||
|
||||
dispatch(actions.doInitialDataLoad(window.location.search));
|
||||
|
||||
/* listen for resize events */
|
||||
window.addEventListener("resize", () => {
|
||||
dispatch({
|
||||
type: "window resize",
|
||||
data: {
|
||||
height: window.innerHeight,
|
||||
width: window.innerWidth
|
||||
}
|
||||
});
|
||||
});
|
||||
dispatch({
|
||||
type: "window resize",
|
||||
data: {
|
||||
height: window.innerHeight,
|
||||
width: window.innerWidth
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_onURLChanged() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch({ type: "url changed", url: document.location.href });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, error, graphRenderCounter } = this.props;
|
||||
return (
|
||||
<Container>
|
||||
<Helmet title="cellxgene" />
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
fontWeight: 500,
|
||||
top: window.innerHeight / 2,
|
||||
left: window.innerWidth / 2 - 50
|
||||
}}
|
||||
>
|
||||
loading cellxgene
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
fontWeight: 500,
|
||||
top: window.innerHeight / 2,
|
||||
left: window.innerWidth / 2 - 50
|
||||
}}
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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,84 @@
|
||||
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
|
||||
}))
|
||||
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 } = this.props;
|
||||
return writableCategoriesEnabled ? (
|
||||
<div
|
||||
id="autosave"
|
||||
style={{
|
||||
position: "fixed",
|
||||
display: "inherit",
|
||||
right: globals.leftSidebarWidth + 5,
|
||||
bottom: 5
|
||||
}}
|
||||
>
|
||||
{this.statusMessage()}
|
||||
<FilenameDialog />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Autosave;
|
||||
@@ -0,0 +1,541 @@
|
||||
/*
|
||||
https://bl.ocks.org/mbostock/4341954
|
||||
https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
|
||||
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
|
||||
*/
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import memoize from "memoize-one";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import { makeContinuousDimensionName } from "../../util/nameCreators";
|
||||
|
||||
@connect((state, 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;
|
||||
histogramCache.x = d3
|
||||
.scaleLinear()
|
||||
.domain([domainMin, domainMax])
|
||||
.range([0, this.width - this.marginRight]);
|
||||
|
||||
histogramCache.bins = d3
|
||||
.histogram()
|
||||
.domain(histogramCache.x.domain())
|
||||
.thresholds(40)(values);
|
||||
|
||||
const yMax = histogramCache.bins
|
||||
.map(b => b.length)
|
||||
.reduce((a, b) => Math.max(a, b));
|
||||
histogramCache.y = d3
|
||||
.scaleLinear()
|
||||
.domain([0, yMax])
|
||||
.range([this.height - this.marginBottom, 0]);
|
||||
|
||||
return histogramCache;
|
||||
});
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.width = 340;
|
||||
this.height = 100;
|
||||
this.marginBottom = 20; // space for X axis & labels
|
||||
this.marginRight = 40; // space for Y axis & labels
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { field } = this.props;
|
||||
const { x, y, bins, svgRef } = this._histogram;
|
||||
|
||||
this.renderAxesBrushBins(x, y, bins, svgRef, field);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { field, world } = this.props;
|
||||
const { x, y, bins, svgRef } = this._histogram;
|
||||
let { brushXselection, brushX } = this.state;
|
||||
let forceBrushUpdate = false;
|
||||
|
||||
/*
|
||||
Update our axis if the underlying dataframe column has changed
|
||||
*/
|
||||
const dfColumn = HistogramBrush.getColumn(world, field);
|
||||
const oldDfColumn = HistogramBrush.getColumn(
|
||||
prevProps.world,
|
||||
prevProps.field
|
||||
);
|
||||
if (dfColumn !== oldDfColumn) {
|
||||
({ brushXselection, brushX } = this.renderAxesBrushBins(
|
||||
x,
|
||||
y,
|
||||
bins,
|
||||
svgRef,
|
||||
field
|
||||
));
|
||||
forceBrushUpdate = true;
|
||||
}
|
||||
|
||||
/*
|
||||
if the selection has changed, ensure that the brush correctly reflects
|
||||
the underlying selection.
|
||||
*/
|
||||
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, eventType) {
|
||||
const type = `continuous metadata histogram ${eventType}`;
|
||||
return () => {
|
||||
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
|
||||
|
||||
// ignore programmatically generated events
|
||||
if (!d3.event.sourceEvent) return;
|
||||
// ignore cascading events, which are programmatically generated
|
||||
if (d3.event.sourceEvent.sourceEvent) return;
|
||||
|
||||
if (d3.event.selection) {
|
||||
dispatch({
|
||||
type,
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
isUserDefined,
|
||||
isDiffExp
|
||||
},
|
||||
range: [x(d3.event.selection[0]), x(d3.event.selection[1])]
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type,
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
isUserDefined,
|
||||
isDiffExp
|
||||
},
|
||||
range: null
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onBrushEnd(selection, x) {
|
||||
return () => {
|
||||
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
|
||||
const minAllowedBrushSize = 10;
|
||||
const smallAmountToAvoidInfiniteLoop = 0.1;
|
||||
|
||||
// ignore programmatically generated events
|
||||
if (!d3.event.sourceEvent) return;
|
||||
// ignore cascading events, which are programmatically generated
|
||||
if (d3.event.sourceEvent.sourceEvent) return;
|
||||
|
||||
if (d3.event.selection) {
|
||||
let _range;
|
||||
|
||||
if (
|
||||
d3.event.selection[1] - d3.event.selection[0] >
|
||||
minAllowedBrushSize
|
||||
) {
|
||||
_range = [x(d3.event.selection[0]), x(d3.event.selection[1])];
|
||||
} else {
|
||||
/* the user selected range is too small and will be hidden #587, so take control of it procedurally */
|
||||
/* https://stackoverflow.com/questions/12354729/d3-js-limit-size-of-brush */
|
||||
|
||||
const procedurallyResizedBrushWidth =
|
||||
d3.event.selection[0] +
|
||||
minAllowedBrushSize +
|
||||
smallAmountToAvoidInfiniteLoop; //
|
||||
|
||||
_range = [x(d3.event.selection[0]), x(procedurallyResizedBrushWidth)];
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "continuous metadata histogram end",
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
isUserDefined,
|
||||
isDiffExp
|
||||
},
|
||||
range: _range
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "continuous metadata histogram cancel",
|
||||
selection: field,
|
||||
continuousNamespace: {
|
||||
isObs,
|
||||
isUserDefined,
|
||||
isDiffExp
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
handleSetGeneAsScatterplotX = () => {
|
||||
const { dispatch, field } = this.props;
|
||||
dispatch({
|
||||
type: "set scatterplot x",
|
||||
data: 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,
|
||||
rangeForColorAccessor: ranges
|
||||
});
|
||||
} else if (world.varData.hasCol(field)) {
|
||||
dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field));
|
||||
}
|
||||
};
|
||||
|
||||
removeHistogram = () => {
|
||||
const {
|
||||
dispatch,
|
||||
field,
|
||||
colorAccessor,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor
|
||||
} = this.props;
|
||||
dispatch({
|
||||
type: "clear user defined gene",
|
||||
data: field
|
||||
});
|
||||
if (field === colorAccessor) {
|
||||
dispatch({
|
||||
type: "reset colorscale"
|
||||
});
|
||||
}
|
||||
if (field === scatterplotXXaccessor) {
|
||||
dispatch({
|
||||
type: "set scatterplot x",
|
||||
data: null
|
||||
});
|
||||
}
|
||||
if (field === scatterplotYYaccessor) {
|
||||
dispatch({
|
||||
type: "set scatterplot y",
|
||||
data: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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, svgRef, field) {
|
||||
const svg = d3.select(svgRef);
|
||||
|
||||
/* Remove everything */
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
/* BINS */
|
||||
svg
|
||||
.insert("g", "*")
|
||||
.attr("fill", "#bbb")
|
||||
.selectAll("rect")
|
||||
.data(bins)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("x", d => x(d.x0) + 1)
|
||||
.attr("y", d => y(d.length))
|
||||
.attr("width", d => Math.abs(x(d.x1) - x(d.x0) - 1))
|
||||
.attr("height", d => y(0) - y(d.length));
|
||||
|
||||
/* BRUSH */
|
||||
const brushX = d3
|
||||
.brushX()
|
||||
.extent([[0, 0], [this.width - this.marginRight, this.height]])
|
||||
/*
|
||||
emit start so that the Undoable history can save an undo point
|
||||
upon drag start, and ignore the subsequent intermediate drag events.
|
||||
*/
|
||||
.on("start", this.onBrush(field, x.invert, "start").bind(this))
|
||||
.on("brush", this.onBrush(field, x.invert, "brush").bind(this))
|
||||
.on("end", this.onBrushEnd(field, x.invert).bind(this));
|
||||
const brushXselection = d3
|
||||
.select(svgRef)
|
||||
.append("g")
|
||||
.attr("class", "brush")
|
||||
.attr("data-testid", `${svgRef.dataset.testid}-brush`)
|
||||
.call(brushX);
|
||||
|
||||
/* X AXIS */
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "axis axis--x")
|
||||
.attr("transform", `translate(0,${this.height - this.marginBottom})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(5)
|
||||
.tickFormat(d3.format(".0s"))
|
||||
);
|
||||
|
||||
/* Y AXIS */
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "axis axis--y")
|
||||
.attr("transform", `translate(${this.width - this.marginRight},0)`)
|
||||
.call(
|
||||
d3
|
||||
.axisRight(y)
|
||||
.ticks(3)
|
||||
.tickFormat(d3.format(".0s"))
|
||||
);
|
||||
|
||||
/* axis style */
|
||||
svg.selectAll(".axis text").style("fill", "rgb(80,80,80)");
|
||||
svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)");
|
||||
svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)");
|
||||
|
||||
const newState = { brushX, brushXselection };
|
||||
this.setState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
field,
|
||||
world,
|
||||
colorAccessor,
|
||||
isUserDefined,
|
||||
isDiffExp,
|
||||
logFoldChange,
|
||||
pvalAdj,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
zebra
|
||||
} = this.props;
|
||||
const fieldForId = field.replace(/\s/g, "_");
|
||||
const {
|
||||
min: unclippedRangeMin,
|
||||
max: unclippedRangeMax
|
||||
} = HistogramBrush.getColumn(world, field, false).summarize();
|
||||
const unclippedRangeMinColor =
|
||||
world.clipQuantiles.min === 0 ? "#bbb" : globals.blue;
|
||||
const unclippedRangeMaxColor =
|
||||
world.clipQuantiles.max === 1 ? "#bbb" : globals.blue;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`histogram_${fieldForId}`}
|
||||
data-testid={`histogram-${field}`}
|
||||
data-testclass={
|
||||
isDiffExp
|
||||
? "histogram-diffexp"
|
||||
: isUserDefined
|
||||
? "histogram-user-gene"
|
||||
: "histogram-continuous-metadata"
|
||||
}
|
||||
style={{
|
||||
padding: globals.leftSidebarSectionPadding,
|
||||
backgroundColor: zebra ? globals.lightestGrey : "white"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
paddingBottom: "8px"
|
||||
}}
|
||||
>
|
||||
{isDiffExp || isUserDefined ? (
|
||||
<span>
|
||||
<span
|
||||
style={{ marginRight: 7 }}
|
||||
className="bp3-icon-standard bp3-icon-scatter-plot"
|
||||
/>
|
||||
<ButtonGroup style={{ marginRight: 7 }}>
|
||||
<Button
|
||||
data-testid={`plot-x-${field}`}
|
||||
onClick={this.handleSetGeneAsScatterplotX}
|
||||
active={scatterplotXXaccessor === field}
|
||||
intent={scatterplotXXaccessor === field ? "primary" : "none"}
|
||||
>
|
||||
plot x
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`plot-y-${field}`}
|
||||
onClick={this.handleSetGeneAsScatterplotY}
|
||||
active={scatterplotYYaccessor === field}
|
||||
intent={scatterplotYYaccessor === field ? "primary" : "none"}
|
||||
>
|
||||
plot y
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</span>
|
||||
) : null}
|
||||
{isUserDefined ? (
|
||||
<Button
|
||||
minimal
|
||||
onClick={this.removeHistogram}
|
||||
style={{
|
||||
color: globals.blue,
|
||||
cursor: "pointer",
|
||||
marginLeft: 7
|
||||
}}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip
|
||||
content="Use as color scale"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
onClick={this.handleColorAction}
|
||||
active={colorAccessor === field}
|
||||
intent={colorAccessor === field ? "primary" : "none"}
|
||||
data-testclass="colorby"
|
||||
data-testid={`colorby-${field}`}
|
||||
icon="tint"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<svg
|
||||
width={this.width}
|
||||
height={this.height}
|
||||
id={`histogram_${fieldForId}_svg`}
|
||||
data-testclass="histogram-plot"
|
||||
data-testid={`histogram-${field}-plot`}
|
||||
ref={svgRef => {
|
||||
this.drawHistogram(svgRef);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
>
|
||||
<span style={{ color: unclippedRangeMinColor }}>
|
||||
min {unclippedRangeMin.toPrecision(4)}
|
||||
</span>
|
||||
<span
|
||||
data-testclass="brushable-histogram-field-name"
|
||||
style={{ fontStyle: "italic" }}
|
||||
>
|
||||
{field}
|
||||
</span>
|
||||
<span style={{ color: unclippedRangeMaxColor }}>
|
||||
max {unclippedRangeMax.toPrecision(4)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isDiffExp ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "baseline"
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>log fold change:</strong>
|
||||
{` ${logFoldChange.toPrecision(4)}`}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 7,
|
||||
padding: 2
|
||||
}}
|
||||
>
|
||||
<strong>p-value (adj):</strong>
|
||||
{pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default HistogramBrush;
|
||||
@@ -0,0 +1,3 @@
|
||||
:local(.value):hover {
|
||||
background: rgba(167, 182, 194, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
InputGroup,
|
||||
Dialog,
|
||||
Classes,
|
||||
MenuItem,
|
||||
Colors
|
||||
} from "@blueprintjs/core";
|
||||
import { Select } from "@blueprintjs/select";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import Category from "./category";
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false,
|
||||
schema: state.world?.schema
|
||||
}))
|
||||
class Categories extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
createAnnoModeActive: false,
|
||||
newCategoryText: "",
|
||||
categoryToDuplicate: null
|
||||
};
|
||||
}
|
||||
|
||||
handleCreateUserAnno = () => {
|
||||
const { dispatch } = this.props;
|
||||
const { newCategoryText, categoryToDuplicate } = this.state;
|
||||
dispatch({
|
||||
type: "annotation: create category",
|
||||
data: newCategoryText,
|
||||
categoryToDuplicate
|
||||
});
|
||||
this.setState({
|
||||
createAnnoModeActive: false,
|
||||
categoryToDuplicate: null,
|
||||
newCategoryText: ""
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const { categoricalSelection } = this.props;
|
||||
const allCategoryNames = Object.keys(categoricalSelection);
|
||||
|
||||
/* 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;
|
||||
};
|
||||
|
||||
categoryNameErrorMessage = name => {
|
||||
const err = this.categoryNameError(name);
|
||||
if (err === false) return null;
|
||||
|
||||
const errorMessageMap = {
|
||||
/* map error code to human readable error message */
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Name must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters": "Only alphanumeric, underscore and period allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return <span>{errorMessage}</span>;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
createAnnoModeActive,
|
||||
categoryToDuplicate,
|
||||
newCategoryText
|
||||
} = this.state;
|
||||
const {
|
||||
categoricalSelection,
|
||||
writableCategoriesEnabled,
|
||||
schema
|
||||
} = this.props;
|
||||
if (!categoricalSelection) return null;
|
||||
|
||||
/* all names, sorted in display order. Will be rendered in this order */
|
||||
const allCategoryNames = Object.keys(categoricalSelection).sort();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: globals.leftSidebarSectionPadding
|
||||
}}
|
||||
>
|
||||
{/* READ ONLY CATEGORICAL FIELDS */}
|
||||
{/* this is duplicative but flat, could be abstracted */}
|
||||
{_.map(allCategoryNames, catName =>
|
||||
!schema.annotations.obsByName[catName].writable ? (
|
||||
<Category
|
||||
key={catName}
|
||||
metadataField={catName}
|
||||
createAnnoModeActive={createAnnoModeActive}
|
||||
isUserAnno={false}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{/* WRITEABLE FIELDS */}
|
||||
{_.map(allCategoryNames, catName =>
|
||||
schema.annotations.obsByName[catName].writable ? (
|
||||
<Category
|
||||
key={catName}
|
||||
metadataField={catName}
|
||||
createAnnoModeActive={createAnnoModeActive}
|
||||
isUserAnno
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{writableCategoriesEnabled ? (
|
||||
<div>
|
||||
<Dialog
|
||||
icon="tag"
|
||||
title="Create new category"
|
||||
isOpen={createAnnoModeActive}
|
||||
onClose={this.handleDisableAnnoMode}
|
||||
>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>New, unique category name:</p>
|
||||
<InputGroup
|
||||
autoFocus
|
||||
value={newCategoryText}
|
||||
intent={
|
||||
this.categoryNameError(newCategoryText)
|
||||
? "warning"
|
||||
: "none"
|
||||
}
|
||||
onChange={e =>
|
||||
this.setState({ newCategoryText: e.target.value })
|
||||
}
|
||||
leftIcon="tag"
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 7,
|
||||
visibility: this.categoryNameError(newCategoryText)
|
||||
? "visible"
|
||||
: "hidden",
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{this.categoryNameErrorMessage(newCategoryText)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Optionally duplicate all labels & cell assignments from
|
||||
existing category into new category:
|
||||
</p>
|
||||
<Select
|
||||
items={allCategoryNames}
|
||||
filterable={false}
|
||||
itemRenderer={(d, { handleClick }) => {
|
||||
return (
|
||||
<MenuItem onClick={handleClick} key={d} text={d} />
|
||||
);
|
||||
}}
|
||||
noResults={<MenuItem disabled text="No results." />}
|
||||
onItemSelect={d => {
|
||||
this.handleModalDuplicateCategorySelection(d);
|
||||
}}
|
||||
>
|
||||
{/* children become the popover target; render value here */}
|
||||
<Button
|
||||
text={
|
||||
categoryToDuplicate || "None (all cells 'unassigned')"
|
||||
}
|
||||
rightIcon="double-caret-vertical"
|
||||
/>
|
||||
</Select>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Tooltip content="Close this dialog without creating a category.">
|
||||
<Button onClick={this.handleDisableAnnoMode}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
onClick={this.handleCreateUserAnno}
|
||||
disabled={
|
||||
!newCategoryText ||
|
||||
this.categoryNameError(newCategoryText)
|
||||
}
|
||||
intent="primary"
|
||||
type="submit"
|
||||
>
|
||||
Create new category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
<Button onClick={this.handleEnableAnnoMode} intent="primary">
|
||||
Create new category
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Categories;
|
||||
@@ -0,0 +1,582 @@
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||
import { Flipper, Flipped } from "react-flip-toolkit";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
InputGroup,
|
||||
Menu,
|
||||
Dialog,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Classes,
|
||||
Icon,
|
||||
Position,
|
||||
PopoverInteractionKind,
|
||||
Colors
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import Value from "./value";
|
||||
import sortedCategoryValues from "./util";
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
|
||||
@connect(state => ({
|
||||
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,
|
||||
newCategoryText: props.metadataField,
|
||||
newLabelText: ""
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelection, metadataField } = this.props;
|
||||
if (categoricalSelection !== prevProps.categoricalSelection) {
|
||||
const cat = categoricalSelection[metadataField];
|
||||
const categoryCount = {
|
||||
// total number of categories in this dimension
|
||||
totalCatCount: cat.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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activateAddNewLabelMode = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: activate add new label mode",
|
||||
data: metadataField
|
||||
});
|
||||
};
|
||||
|
||||
disableAddNewLabelMode = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: disable add new label mode"
|
||||
});
|
||||
this.setState({
|
||||
newLabelText: ""
|
||||
});
|
||||
};
|
||||
|
||||
handleAddNewLabelToCategory = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const { newLabelText } = this.state;
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
metadataField,
|
||||
newLabelText
|
||||
});
|
||||
this.setState({ newLabelText: "" });
|
||||
};
|
||||
|
||||
activateEditCategoryMode = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
|
||||
dispatch({
|
||||
type: "annotation: activate category edit mode",
|
||||
data: metadataField
|
||||
});
|
||||
};
|
||||
|
||||
disableEditCategoryMode = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: disable category edit mode"
|
||||
});
|
||||
};
|
||||
|
||||
handleEditCategory = () => {
|
||||
const { dispatch, metadataField, categoricalSelection } = this.props;
|
||||
const { newCategoryText } = this.state;
|
||||
|
||||
const allCategoryNames = _.keys(categoricalSelection);
|
||||
|
||||
if (
|
||||
(allCategoryNames.indexOf(newCategoryText) > -1 &&
|
||||
newCategoryText !== metadataField) ||
|
||||
newCategoryText === ""
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "annotation: category edited",
|
||||
metadataField,
|
||||
newCategoryText,
|
||||
data: newCategoryText
|
||||
});
|
||||
};
|
||||
|
||||
handleDeleteCategory = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: delete category",
|
||||
metadataField
|
||||
});
|
||||
};
|
||||
|
||||
handleColorChange = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "color by categorical metadata",
|
||||
colorAccessor: metadataField
|
||||
});
|
||||
};
|
||||
|
||||
labelNameError = 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;
|
||||
|
||||
/* check for label syntax errors */
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
|
||||
if (error) return error;
|
||||
|
||||
/* disallow duplicates */
|
||||
const { metadataField, universe } = this.props;
|
||||
const { obsByName } = universe.schema.annotations;
|
||||
if (obsByName[metadataField].categories.indexOf(name) !== -1)
|
||||
return "duplicate";
|
||||
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
};
|
||||
|
||||
labelNameErrorMessage = name => {
|
||||
const { metadataField } = this.props;
|
||||
const err = this.labelNameError(name);
|
||||
|
||||
if (err === "duplicate") {
|
||||
/* duplicate error is special cased because it has special formatting */
|
||||
return (
|
||||
<span>
|
||||
<span style={{ fontStyle: "italic" }}>{name}</span> already exists
|
||||
already exists within{" "}
|
||||
<span style={{ fontStyle: "italic" }}>{metadataField}</span>{" "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
/* 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, underscore and period allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return <span>{errorMessage}</span>;
|
||||
}
|
||||
|
||||
/* no error, no message generated */
|
||||
return null;
|
||||
};
|
||||
|
||||
categoryNameErrorMessage = () => {
|
||||
const { newCategoryText } = this.state;
|
||||
const err = this.editedCategoryNameError();
|
||||
if (err === false) return null;
|
||||
|
||||
const errorMessageMap = {
|
||||
/* map error code to human readable error message */
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Category name must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters": "Only alphanumeric, underscore and period allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
editedCategoryNameError = () => {
|
||||
const { metadataField, categoricalSelection } = this.props;
|
||||
const { newCategoryText } = this.state;
|
||||
|
||||
/* check for syntax errors in category name */
|
||||
const error = AnnotationsHelpers.annotationNameIsErroneous(newCategoryText);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/* check for duplicative categories */
|
||||
const allCategoryNames = _.keys(categoricalSelection);
|
||||
const categoryNameAlreadyExists =
|
||||
allCategoryNames.indexOf(newCategoryText) > -1;
|
||||
const sameName = newCategoryText === metadataField;
|
||||
if (categoryNameAlreadyExists && !sameName) {
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
/* otherwise, no error */
|
||||
return false;
|
||||
};
|
||||
|
||||
toggleAll() {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter all of these",
|
||||
metadataField
|
||||
});
|
||||
this.setState({ isChecked: true });
|
||||
}
|
||||
|
||||
toggleNone() {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter none of these",
|
||||
metadataField
|
||||
});
|
||||
this.setState({ isChecked: false });
|
||||
}
|
||||
|
||||
handleToggleAllClick() {
|
||||
const { isChecked } = this.state;
|
||||
// || this.checkbox.indeterminate === false
|
||||
if (isChecked) {
|
||||
this.toggleNone();
|
||||
} else if (!isChecked) {
|
||||
this.toggleAll();
|
||||
}
|
||||
}
|
||||
|
||||
renderCategoryItems(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 { isExpanded, isChecked, newLabelText, newCategoryText } = this.state;
|
||||
const {
|
||||
metadataField,
|
||||
colorAccessor,
|
||||
categoricalSelection,
|
||||
isUserAnno,
|
||||
annotations
|
||||
} = this.props;
|
||||
const { isTruncated } = categoricalSelection[metadataField];
|
||||
|
||||
const cat = categoricalSelection[metadataField];
|
||||
const optTuples = sortedCategoryValues(isUserAnno, [
|
||||
...cat.categoryValueIndices
|
||||
]);
|
||||
const optTuplesAsKey = _.map(optTuples, t => t[0]).join(""); // animation
|
||||
const allCategoryNames = _.keys(categoricalSelection);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: globals.maxControlsWidth
|
||||
}}
|
||||
data-testclass="category"
|
||||
data-testid={`category-${metadataField}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-start"
|
||||
}}
|
||||
>
|
||||
<label className="bp3-control bp3-checkbox">
|
||||
<input
|
||||
data-testclass="category-select"
|
||||
data-testid={`category-select-${metadataField}`}
|
||||
onChange={this.handleToggleAllClick.bind(this)}
|
||||
ref={el => {
|
||||
this.checkbox = el;
|
||||
return el;
|
||||
}}
|
||||
checked={isChecked}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="bp3-control-indicator" />
|
||||
</label>
|
||||
<span
|
||||
data-testid={`category-expand-${metadataField}`}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-block"
|
||||
}}
|
||||
onClick={() => {
|
||||
const editingCategory =
|
||||
annotations.isEditingCategoryName &&
|
||||
annotations.categoryBeingEdited === metadataField;
|
||||
if (!editingCategory) {
|
||||
this.setState({ isExpanded: !isExpanded });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isUserAnno ? (
|
||||
<Icon style={{ marginRight: 5 }} icon="tag" iconSize={16} />
|
||||
) : null}
|
||||
|
||||
{annotations.isEditingCategoryName &&
|
||||
annotations.categoryBeingEdited === metadataField ? (
|
||||
<form
|
||||
style={{ display: "inline-block" }}
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
this.handleEditCategory();
|
||||
}}
|
||||
>
|
||||
<InputGroup
|
||||
style={{ position: "relative", top: -1 }}
|
||||
ref={input => {
|
||||
this.editableCategoryInput = input;
|
||||
}}
|
||||
small
|
||||
autoFocus
|
||||
onChange={e => {
|
||||
this.setState({
|
||||
newCategoryText: e.target.value
|
||||
});
|
||||
}}
|
||||
defaultValue={metadataField}
|
||||
rightElement={
|
||||
<Button
|
||||
minimal
|
||||
disabled={this.editedCategoryNameError()}
|
||||
style={{ position: "relative", top: -1 }}
|
||||
type="button"
|
||||
icon="small-tick"
|
||||
data-testclass="submitCategoryNameEdit"
|
||||
data-testid="submitCategoryNameEdit"
|
||||
onClick={this.handleEditCategory}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{this.categoryNameErrorMessage()}
|
||||
</form>
|
||||
) : (
|
||||
metadataField
|
||||
)}
|
||||
|
||||
{isExpanded ? (
|
||||
<FaChevronDown
|
||||
data-testclass="category-expand-is-expanded"
|
||||
style={{ fontSize: 10, marginLeft: 5 }}
|
||||
/>
|
||||
) : (
|
||||
<FaChevronRight
|
||||
data-testclass="category-expand-is-not-expanded"
|
||||
style={{ fontSize: 10, marginLeft: 5 }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{isUserAnno ? (
|
||||
<>
|
||||
<Dialog
|
||||
icon="tag"
|
||||
title="Add new label"
|
||||
isOpen={
|
||||
annotations.isAddingNewLabel &&
|
||||
annotations.categoryAddingNewLabel === metadataField
|
||||
}
|
||||
onClose={this.disableAddNewLabelMode}
|
||||
>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
this.handleAddNewLabelToCategory();
|
||||
}}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>New, unique label name:</p>
|
||||
<InputGroup
|
||||
autoFocus
|
||||
value={newLabelText}
|
||||
intent={
|
||||
this.labelNameError(newLabelText)
|
||||
? "warning"
|
||||
: "none"
|
||||
}
|
||||
onChange={e =>
|
||||
this.setState({ newLabelText: e.target.value })
|
||||
}
|
||||
leftIcon="tag"
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 7,
|
||||
visibility: this.labelNameError(newLabelText)
|
||||
? "visible"
|
||||
: "hidden",
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{this.labelNameErrorMessage(newLabelText)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Tooltip content="Close this dialog without adding a label.">
|
||||
<Button onClick={this.disableAddNewLabelMode}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabled={
|
||||
!newLabelText || this.labelNameError(newLabelText)
|
||||
}
|
||||
onClick={this.handleAddNewLabelToCategory}
|
||||
intent="primary"
|
||||
type="submit"
|
||||
>
|
||||
Add new label to category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER}
|
||||
boundary="window"
|
||||
position={Position.RIGHT_TOP}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="tag"
|
||||
data-testclass="handleAddNewLabelToCategory"
|
||||
data-testid={`handleAddNewLabelToCategory-${metadataField}`}
|
||||
onClick={this.activateAddNewLabelMode}
|
||||
text="Add a new label to this category"
|
||||
/>
|
||||
<MenuItem
|
||||
icon="edit"
|
||||
disabled={annotations.isEditingCategoryName}
|
||||
data-testclass="activateEditCategoryMode"
|
||||
data-testid={`activateEditCategoryMode-${metadataField}`}
|
||||
onClick={this.activateEditCategoryMode}
|
||||
text="Edit this category's name"
|
||||
/>
|
||||
<MenuItem
|
||||
icon="delete"
|
||||
intent="danger"
|
||||
data-testclass="handleDeleteCategory"
|
||||
data-testid={`handleDeleteCategory-${metadataField}`}
|
||||
onClick={this.handleDeleteCategory}
|
||||
text="Delete this category, all associated labels, and remove all cell assignments"
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
style={{ marginLeft: 0 }}
|
||||
data-testclass="seeActions"
|
||||
data-testid={`seeActions-${metadataField}`}
|
||||
icon="more"
|
||||
minimal
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
) : null}
|
||||
<Tooltip
|
||||
content="Use as color scale"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
data-testclass="colorby"
|
||||
data-testid={`colorby-${metadataField}`}
|
||||
onClick={this.handleColorChange}
|
||||
active={colorAccessor === metadataField}
|
||||
intent={colorAccessor === metadataField ? "primary" : "none"}
|
||||
icon="tint"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</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,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;
|
||||
@@ -0,0 +1,47 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
// values is [ [optVal, optIdx], ...]
|
||||
// index is range array
|
||||
// return sorted index
|
||||
|
||||
/*
|
||||
Sort category values (labels) in the order we want for presentation.
|
||||
|
||||
TL;DR: numeric sort for number-like strings, then strings in case-ignoring alpha
|
||||
order. Except, when isUserAnno is true, pin globals.unassignedCategoryLabel
|
||||
to the end.
|
||||
|
||||
*/
|
||||
|
||||
import isNumber from "is-number";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
const sortedCategoryValues = (isUserAnno, values) => {
|
||||
/* this sort could be memoized for perf */
|
||||
|
||||
const strings = [];
|
||||
const ints = [];
|
||||
const unassigned = [];
|
||||
|
||||
values.forEach(v => {
|
||||
if (isUserAnno && v[0] === globals.unassignedCategoryLabel) {
|
||||
unassigned.push(v);
|
||||
} else if (isNumber(v[0])) {
|
||||
ints.push(v);
|
||||
} else {
|
||||
strings.push(v);
|
||||
}
|
||||
});
|
||||
|
||||
strings.sort((a, b) => {
|
||||
const textA = String(a[0]).toUpperCase();
|
||||
const textB = String(b[0]).toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||
});
|
||||
|
||||
ints.sort((a, b) => +a[0] - +b[0]);
|
||||
|
||||
return ints.concat(strings, unassigned);
|
||||
};
|
||||
|
||||
export default sortedCategoryValues;
|
||||
@@ -0,0 +1,586 @@
|
||||
// jshint esversion: 6
|
||||
import { connect } from "react-redux";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
InputGroup,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Position,
|
||||
PopoverInteractionKind,
|
||||
Tooltip,
|
||||
Colors
|
||||
} from "@blueprintjs/core";
|
||||
import Occupancy from "./occupancy";
|
||||
import * as globals from "../../globals";
|
||||
import styles from "./categorical.css";
|
||||
|
||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||
|
||||
@connect(state => ({
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
schema: state.world?.schema,
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter
|
||||
}))
|
||||
class CategoryValue extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
editedLabelText: String(
|
||||
props.categoricalSelection[props.metadataField].categoryValues[
|
||||
props.categoryIndex
|
||||
]
|
||||
).valueOf()
|
||||
};
|
||||
}
|
||||
|
||||
getLabel = () => {
|
||||
const { metadataField, categoryIndex, categoricalSelection } = this.props;
|
||||
const category = categoricalSelection[metadataField];
|
||||
const label = category.categoryValues[categoryIndex];
|
||||
|
||||
return label;
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
const { editedLabelText } = this.state;
|
||||
const label = this.getLabel();
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
editedLabel: editedLabelText,
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
label
|
||||
});
|
||||
};
|
||||
|
||||
valueNameErrorMessage = () => {
|
||||
const err = this.valueNameError();
|
||||
if (err === false) return null;
|
||||
|
||||
const errorMessageMap = {
|
||||
/* map error code to human readable error message */
|
||||
"empty-string": "Blank names not allowed",
|
||||
duplicate: "Label must be unique",
|
||||
"trim-spaces": "Leading and trailing spaces not allowed",
|
||||
"illegal-characters": "Only alphanumeric, underscore and period allowed",
|
||||
"multi-space-run": "Multiple consecutive spaces not allowed"
|
||||
};
|
||||
const errorMessage = errorMessageMap[err] ?? "error";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: Colors.ORANGE3
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
valueNameError = () => {
|
||||
const { editedLabelText } = this.state;
|
||||
const { categoricalSelection, metadataField, categoryIndex } = this.props;
|
||||
|
||||
/*
|
||||
check label syntax
|
||||
*/
|
||||
const err = AnnotationsHelpers.annotationNameIsErroneous(editedLabelText);
|
||||
if (err) return err;
|
||||
|
||||
/*
|
||||
disallow duplicates
|
||||
*/
|
||||
const category = categoricalSelection[metadataField];
|
||||
const displayString = String(
|
||||
category.categoryValues[categoryIndex]
|
||||
).valueOf();
|
||||
if (
|
||||
category.categoryValues.indexOf(editedLabelText) > -1 &&
|
||||
editedLabelText !== displayString
|
||||
)
|
||||
return "duplicate";
|
||||
|
||||
/*
|
||||
otherwise, all good!
|
||||
*/
|
||||
return false;
|
||||
};
|
||||
|
||||
activateEditLabelMode = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: activate edit label mode",
|
||||
metadataField,
|
||||
categoryIndex
|
||||
});
|
||||
};
|
||||
|
||||
cancelEdit = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: cancel edit label mode",
|
||||
metadataField,
|
||||
categoryIndex
|
||||
});
|
||||
};
|
||||
|
||||
toggleOff = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter deselect",
|
||||
metadataField,
|
||||
categoryIndex
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
valueSelectionChange ||
|
||||
worldChange ||
|
||||
colorAccessorChange ||
|
||||
annotationsChange ||
|
||||
crossfilterChange ||
|
||||
editingLabel
|
||||
);
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelection, metadataField, categoryIndex } = this.props;
|
||||
if (
|
||||
prevProps.categoricalSelection !== categoricalSelection ||
|
||||
prevProps.metadataField !== metadataField ||
|
||||
prevProps.categoryIndex !== categoryIndex
|
||||
) {
|
||||
this.setState({
|
||||
editedLabelText: String(
|
||||
categoricalSelection[metadataField].categoryValues[categoryIndex]
|
||||
).valueOf()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleOn = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
dispatch({
|
||||
type: "categorical metadata filter select",
|
||||
metadataField,
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
categoricalSelection,
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
colorAccessor,
|
||||
colorScale,
|
||||
i,
|
||||
schema,
|
||||
isUserAnno,
|
||||
annotations,
|
||||
// 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
|
||||
} = this.props;
|
||||
|
||||
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 = String(
|
||||
category.categoryValues[categoryIndex]
|
||||
).valueOf();
|
||||
|
||||
/* this is the color scale, so add swatches below */
|
||||
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
|
||||
)}`;
|
||||
}
|
||||
|
||||
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={styles.value}
|
||||
data-testclass="categorical-row"
|
||||
style={{
|
||||
padding: "4px 7px",
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "2px",
|
||||
borderRadius: "2px"
|
||||
}}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseExit}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
userSelect: "none",
|
||||
width: globals.leftSidebarWidth - 145,
|
||||
display: "flex",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
}}
|
||||
>
|
||||
{annotations.isEditingLabelName &&
|
||||
annotations.labelEditable.category === metadataField &&
|
||||
annotations.labelEditable.label === categoryIndex
|
||||
? null
|
||||
: truncatedString || displayString}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{isUserAnno &&
|
||||
annotations.labelEditable.category === metadataField &&
|
||||
annotations.isEditingLabelName &&
|
||||
annotations.labelEditable.label === categoryIndex ? (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (this.valueNameError()) {
|
||||
return;
|
||||
}
|
||||
this.handleEditValue();
|
||||
}}
|
||||
>
|
||||
<InputGroup
|
||||
style={{ position: "relative", top: -1 }}
|
||||
ref={input => {
|
||||
this.editableInput = input;
|
||||
}}
|
||||
small
|
||||
autoFocus
|
||||
intent={this.valueNameError() ? "warning" : "none"}
|
||||
onChange={e => {
|
||||
this.setState({ editedLabelText: e.target.value });
|
||||
}}
|
||||
defaultValue={displayString}
|
||||
rightElement={
|
||||
<Button
|
||||
minimal
|
||||
style={{ position: "relative", top: -1 }}
|
||||
disabled={this.valueNameError()}
|
||||
type="button"
|
||||
icon="small-tick"
|
||||
data-testclass="submitEdit"
|
||||
data-testid="submitEdit"
|
||||
onClick={this.handleEditValue}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{this.valueNameErrorMessage()}
|
||||
</form>
|
||||
) : null}
|
||||
{/*
|
||||
CANCEL IT, WITH BUTTON, ESCAPE KEY, CLICK OUT, UNDO?
|
||||
|
||||
<Button
|
||||
minimal
|
||||
style={{ position: "relative", top: -1 }}
|
||||
type="button"
|
||||
icon="cross"
|
||||
data-testclass="submitEdit"
|
||||
data-testid="submitEdit"
|
||||
onClick={this.cancelEdit}
|
||||
/> */}
|
||||
</div>
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
|
||||
<Occupancy category={category} {...this.props} />
|
||||
) : null}
|
||||
</span>
|
||||
</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"
|
||||
}}
|
||||
/>
|
||||
{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={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
|
||||
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={`handleEditValue-${metadataField}`}
|
||||
onClick={this.activateEditLabelMode}
|
||||
disabled={annotations.isEditingLabelName}
|
||||
/>
|
||||
) : null}
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="delete"
|
||||
intent="danger"
|
||||
data-testclass="handleDeleteValue"
|
||||
data-testid={`handleDeleteValue-${metadataField}`}
|
||||
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={`seeActions-${metadataField}`}
|
||||
icon="more"
|
||||
small
|
||||
minimal
|
||||
/>
|
||||
</Popover>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CategoryValue;
|
||||
@@ -0,0 +1,100 @@
|
||||
// jshint esversion: 6
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
import HistogramBrush from "../brushableHistogram";
|
||||
|
||||
@connect(state => ({
|
||||
obsAnnotations: 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 => {
|
||||
return () => {
|
||||
const { dispatch, obsAnnotations } = this.props;
|
||||
const summary = obsAnnotations.col(key).summarize();
|
||||
dispatch({
|
||||
type: "color by continuous metadata",
|
||||
colorAccessor: key,
|
||||
rangeForColorAccessor: summary
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
render() {
|
||||
const { obsAnnotations, schema } = this.props;
|
||||
if (schema && !this.continuousChecked) {
|
||||
this.hasContinuous = _.some(
|
||||
schema.annotations.obs,
|
||||
d => d.type === "int32" || d.type === "float32"
|
||||
);
|
||||
this.continuousChecked = true; /* only do this once */
|
||||
}
|
||||
|
||||
/* initial value for iterator to simulate index, ranges is an object */
|
||||
let zebra = 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.hasContinuous ? (
|
||||
<p
|
||||
style={{
|
||||
...globals.leftSidebarSectionHeading,
|
||||
marginTop: 40,
|
||||
paddingLeft: globals.leftSidebarSectionPadding
|
||||
}}
|
||||
>
|
||||
Continuous metadata
|
||||
</p>
|
||||
) : null}
|
||||
{obsAnnotations
|
||||
? _.map(obsAnnotations.colIndex.keys(), key => {
|
||||
const isColorField =
|
||||
key.includes("color") || key.includes("Color");
|
||||
if (key === schema.annotations.obs.index || isColorField)
|
||||
return null;
|
||||
|
||||
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}
|
||||
ranges={summary}
|
||||
handleColorAction={this.handleColorAction(key)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Continuous;
|
||||
+20
-20
@@ -7,24 +7,24 @@ body {
|
||||
}
|
||||
*/
|
||||
|
||||
.parcoords {
|
||||
:local(.parcoords) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.parcoords svg,
|
||||
.parcoords canvas {
|
||||
:local(.parcoords) svg,
|
||||
:local(.parcoords) canvas {
|
||||
font: 10px sans-serif;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.parcoords canvas {
|
||||
:local(.parcoords) canvas {
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.axis .title {
|
||||
:local(.axis .title) {
|
||||
font-size: 10px;
|
||||
transform: rotate(-21deg) translate(-5px,-6px);
|
||||
transform: rotate(-21deg) translate(-5px, -6px);
|
||||
fill: #222;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -32,14 +32,14 @@ body {
|
||||
/* -webkit-filter: grayscale(100%);
|
||||
filter: grayscale(100%); */
|
||||
|
||||
.axis line,
|
||||
.axis path {
|
||||
:local(.axis) line,
|
||||
:local(.axis) path {
|
||||
fill: none;
|
||||
stroke: #ccc;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.axis .tick text {
|
||||
:local(.axis .tick) text {
|
||||
fill: #222;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -52,39 +52,39 @@ old, from reference bl.ocks
|
||||
}
|
||||
*/
|
||||
|
||||
.axis:hover line,
|
||||
.axis:hover path,
|
||||
.axis.active line,
|
||||
.axis.active path {
|
||||
:local(.axis:hover) line,
|
||||
:local(.axis:hover) path,
|
||||
:local(.axis.active) line,
|
||||
:local(.axis.active) path {
|
||||
fill: none;
|
||||
stroke: #222;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.axis:hover .title {
|
||||
:local(.axis:hover .title) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.axis:hover .tick text {
|
||||
:local(.axis:hover .tick) text {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.axis.active .title {
|
||||
:local(.axis.active .title) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.axis.active .tick text {
|
||||
:local(.axis.active .tick) text {
|
||||
opacity: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.brush .extent {
|
||||
fill-opacity: .3;
|
||||
:local(.brush .extent) {
|
||||
fill-opacity: 0.3;
|
||||
stroke: #fff;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.pre {
|
||||
:local(.pre) {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin: 6px 12px;
|
||||
+12
-10
@@ -4,26 +4,28 @@
|
||||
******************************************
|
||||
******************************************/
|
||||
// jshint esversion: 6
|
||||
const setupParallelCoordinates = (width, height, margin) => {
|
||||
var container = d3.select("#parcoords");
|
||||
import * as d3 from "d3";
|
||||
|
||||
var svg = container
|
||||
const setupParallelCoordinates = (width, height, margin) => {
|
||||
const container = d3.select("#parcoords");
|
||||
|
||||
const svg = container
|
||||
.append("svg")
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom)
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
var canvas = container
|
||||
const canvas = container
|
||||
.append("canvas")
|
||||
.attr("width", width * devicePixelRatio)
|
||||
.attr("height", height * devicePixelRatio)
|
||||
.style("width", width + "px")
|
||||
.style("height", height + "px")
|
||||
.style("margin-top", margin.top + "px")
|
||||
.style("margin-left", margin.left + "px");
|
||||
.style("width", `${width}px`)
|
||||
.style("height", `${height}px`)
|
||||
.style("margin-top", `${margin.top}px`)
|
||||
.style("margin-left", `${margin.left}px`);
|
||||
|
||||
var ctx = canvas.node().getContext("2d");
|
||||
const ctx = canvas.node().getContext("2d");
|
||||
ctx.globalCompositeOperation = "darken";
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.lineWidth = 1.5;
|
||||
@@ -1,5 +1,6 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import * as d3 from "d3";
|
||||
|
||||
const paddingRight = 120;
|
||||
const continuousChartWidth = 1200;
|
||||
@@ -17,11 +18,10 @@ export const createDimensions = data => {
|
||||
_.each(data, (value, key) => {
|
||||
if (value.range) {
|
||||
newArr.push({
|
||||
key: key /* room for confusion: lodash calls this key, it's also the name of the property parallel coords code is looking for */,
|
||||
key /* room for confusion: lodash calls this key, it's also the name of the property parallel coords code is looking for */,
|
||||
type: {
|
||||
within: (d, extent, dim) => {
|
||||
return extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1];
|
||||
}
|
||||
within: (d, extent, dim) =>
|
||||
extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1]
|
||||
},
|
||||
scale: d3
|
||||
.scaleSqrt()
|
||||
@@ -39,19 +39,12 @@ export const brushstart = () => {
|
||||
d3.event.sourceEvent.stopPropagation();
|
||||
};
|
||||
|
||||
export const d3_functor = v => {
|
||||
return typeof v === "function"
|
||||
? v
|
||||
: () => {
|
||||
return v;
|
||||
};
|
||||
};
|
||||
|
||||
export const project = (d, dimensions, xscale) => {
|
||||
return dimensions.map((p, i) => {
|
||||
// Unused.
|
||||
// export const d3_functor = v => (typeof v === "function" ? v : () => v);
|
||||
export const project = (d, dimensions, xscale) =>
|
||||
dimensions.map((p, i) => {
|
||||
// check if data element has property and contains a value
|
||||
if (!(p.key in d) || d[p.key] === null) return null;
|
||||
|
||||
return [xscale(i), p.scale(d[p.key])];
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import { interpolateCool } from "d3-scale-chromatic";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
// create continuous color legend
|
||||
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
||||
const continuous = (selectorId, colorscale, colorAccessor) => {
|
||||
const legendheight = 200;
|
||||
const legendwidth = 80;
|
||||
const margin = { top: 10, right: 60, bottom: 10, left: 2 };
|
||||
|
||||
const canvas = d3
|
||||
.select(selectorId)
|
||||
.style("height", `${legendheight}px`)
|
||||
.style("width", `${legendwidth}px`)
|
||||
.append("canvas")
|
||||
.attr("height", legendheight - margin.top - margin.bottom)
|
||||
.attr("width", 1)
|
||||
.style("height", `${legendheight - margin.top - margin.bottom}px`)
|
||||
.style("width", `${legendwidth - margin.left - margin.right}px`)
|
||||
.style("position", "absolute")
|
||||
.style("top", `${margin.top + 1}px`)
|
||||
.style("left", `${margin.left + 1}px`)
|
||||
.style(
|
||||
"transform",
|
||||
"scale(1,-1)"
|
||||
) /* flip it! dark is high value light is low.
|
||||
we flip the color scale as well [1, 0] instead of [0, 1] */
|
||||
.node();
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const legendscale = d3
|
||||
.scaleLinear()
|
||||
.range([1, legendheight - margin.top - margin.bottom])
|
||||
.domain([
|
||||
colorscale.domain()[1],
|
||||
colorscale.domain()[0]
|
||||
]); /* we flip this to make viridis colors dark if high in the color scale */
|
||||
|
||||
// image data hackery based on http://bl.ocks.org/mbostock/048d21cf747371b11884f75ad896e5a5
|
||||
const image = ctx.createImageData(1, legendheight);
|
||||
d3.range(legendheight).forEach(i => {
|
||||
const c = d3.rgb(colorscale(legendscale.invert(i)));
|
||||
image.data[4 * i] = c.r;
|
||||
image.data[4 * i + 1] = c.g;
|
||||
image.data[4 * i + 2] = c.b;
|
||||
image.data[4 * i + 3] = 255;
|
||||
});
|
||||
ctx.putImageData(image, 0, 0);
|
||||
|
||||
// A simpler way to do the above, but possibly slower. keep in mind the legend
|
||||
// width is stretched because the width attr of the canvas is 1
|
||||
// See http://stackoverflow.com/questions/4899799/whats-the-best-way-to-set-a-single-pixel-in-an-html5-canvas
|
||||
/*
|
||||
d3.range(legendheight).forEach(function(i) {
|
||||
ctx.fillStyle = colorscale(legendscale.invert(i));
|
||||
ctx.fillRect(0,i,1,1);
|
||||
});
|
||||
*/
|
||||
|
||||
const legendaxis = d3
|
||||
.axisRight()
|
||||
.scale(legendscale)
|
||||
.tickSize(6)
|
||||
.ticks(8);
|
||||
|
||||
const svg = d3
|
||||
.select(selectorId)
|
||||
.append("svg")
|
||||
.attr("height", `${legendheight}px`)
|
||||
.attr("width", `${legendwidth}px`)
|
||||
.style("position", "absolute")
|
||||
.style("left", "0px")
|
||||
.style("top", "0px");
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "axis")
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${legendwidth - margin.left - margin.right + 3},${margin.top})`
|
||||
)
|
||||
.call(legendaxis);
|
||||
|
||||
// text label for the y axis
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("y", 2)
|
||||
.attr("x", 0 - legendheight / 2)
|
||||
.attr("dy", "1em")
|
||||
.style("text-anchor", "middle")
|
||||
.style("fill", "white")
|
||||
.text(colorAccessor);
|
||||
};
|
||||
|
||||
@connect(state => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
responsive: state.responsive
|
||||
}))
|
||||
class ContinuousLegend extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { colorAccessor, responsive, colorScale } = this.props;
|
||||
if (
|
||||
prevProps.colorAccessor !== colorAccessor ||
|
||||
prevProps.colorScale !== colorScale ||
|
||||
prevProps.responsive.height !== responsive.height ||
|
||||
prevProps.responsive.width !== responsive.width
|
||||
) {
|
||||
/* always remove it, if it's not continuous we don't put it back. */
|
||||
d3.select("#continuous_legend")
|
||||
.selectAll("*")
|
||||
.remove();
|
||||
}
|
||||
|
||||
if (colorAccessor && colorScale && colorScale.range) {
|
||||
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
|
||||
if (colorScale.range()[0][0] !== "#") {
|
||||
continuous(
|
||||
"#continuous_legend",
|
||||
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
|
||||
colorAccessor
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { colorAccessor, responsive } = this.props;
|
||||
return (
|
||||
<div
|
||||
id="continuous_legend"
|
||||
style={{
|
||||
position: "fixed",
|
||||
display: colorAccessor ? "inherit" : "none",
|
||||
right: globals.leftSidebarWidth,
|
||||
top: responsive.height / 2
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ContinuousLegend;
|
||||
@@ -1,4 +1,4 @@
|
||||
.btn {
|
||||
:local(.btn) {
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: "#FFF";
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
.container {
|
||||
:local(.container) {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
|
||||
import styles from "./container.css";
|
||||
|
||||
const Container = props => {
|
||||
const { children } = props;
|
||||
return <div className={styles.container}>{children}</div>;
|
||||
};
|
||||
|
||||
export default Container;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Position, Toaster, Intent } from "@blueprintjs/core";
|
||||
|
||||
/** Singleton toaster instance. Create separate instances for different options. */
|
||||
|
||||
const ErrorToastTopCenter = Toaster.create({
|
||||
className: "recipe-toaster",
|
||||
position: Position.TOP
|
||||
});
|
||||
|
||||
/*
|
||||
A "user" error - eg, bad input
|
||||
*/
|
||||
export const postUserErrorToast = message =>
|
||||
ErrorToastTopCenter.show({ message, intent: Intent.WARNING });
|
||||
|
||||
/*
|
||||
A toast the user must dismiss manually, because they need to act on its information,
|
||||
ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix.
|
||||
*/
|
||||
export const keepAroundErrorToast = message =>
|
||||
ErrorToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING });
|
||||
|
||||
/*
|
||||
a hard network error
|
||||
*/
|
||||
export const postNetworkErrorToast = message =>
|
||||
ErrorToastTopCenter.show({
|
||||
message,
|
||||
timeout: 30000,
|
||||
intent: Intent.DANGER
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
// jshint esversion: 6
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import fuzzysort from "fuzzysort";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
MenuItem,
|
||||
Button,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
ControlGroup
|
||||
} from "@blueprintjs/core";
|
||||
import { Suggest } from "@blueprintjs/select";
|
||||
import HistogramBrush from "../brushableHistogram";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import {
|
||||
postUserErrorToast,
|
||||
keepAroundErrorToast
|
||||
} from "../framework/toasters";
|
||||
|
||||
import { 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 => {
|
||||
return {
|
||||
obsAnnotations: state.world?.obsAnnotations,
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||
world: state.world,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
differential: state.differential
|
||||
};
|
||||
})
|
||||
class GeneExpression extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
bulkAdd: "",
|
||||
tab: "autosuggest",
|
||||
activeItem: null
|
||||
};
|
||||
}
|
||||
|
||||
_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(g) {
|
||||
const { world, dispatch, userDefinedGenes } = this.props;
|
||||
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) {
|
||||
postUserErrorToast(
|
||||
"That's too many genes, you can have at most 15 user defined genes"
|
||||
);
|
||||
} else if (
|
||||
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
|
||||
) {
|
||||
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||
} else {
|
||||
dispatch({ type: "single user defined gene start" });
|
||||
dispatch(actions.requestUserDefinedGene(gene)).then(
|
||||
() => dispatch({ type: "single user defined gene complete" }),
|
||||
() => dispatch({ type: "single user defined gene error" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
world,
|
||||
userDefinedGenes,
|
||||
userDefinedGenesLoading,
|
||||
differential
|
||||
} = this.props;
|
||||
const varIndexName = world?.schema?.annotations?.var?.index;
|
||||
const { tab, bulkAdd, activeItem } = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: globals.leftSidebarSectionPadding
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
active={tab === "autosuggest"}
|
||||
style={{ marginRight: 5 }}
|
||||
minimal
|
||||
small
|
||||
data-testid="tab-autosuggest"
|
||||
onClick={() => {
|
||||
this.setState({ tab: "autosuggest" });
|
||||
}}
|
||||
>
|
||||
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={
|
||||
world && world.varAnnotations
|
||||
? world.varAnnotations.col(varIndexName).asArray()
|
||||
: ["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.varData.col(geneName);
|
||||
if (!values) {
|
||||
return null;
|
||||
}
|
||||
const summary = values.summarize();
|
||||
return (
|
||||
<HistogramBrush
|
||||
key={geneName}
|
||||
field={geneName}
|
||||
zebra={index % 2 === 0}
|
||||
ranges={summary}
|
||||
isUserDefined
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
<div>
|
||||
{differential.diffExp
|
||||
? _.map(differential.diffExp, (value, index) => {
|
||||
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={summary}
|
||||
isDiffExp
|
||||
logFoldChange={value[1]}
|
||||
pval={value[2]}
|
||||
pvalAdj={value[3]}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GeneExpression;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { glPointFlags, glPointSize } from "../../util/glHelpers";
|
||||
|
||||
export default function(regl) {
|
||||
return regl({
|
||||
vert: `
|
||||
precision mediump float;
|
||||
|
||||
attribute vec2 position;
|
||||
attribute vec3 color;
|
||||
attribute float flag;
|
||||
|
||||
uniform float distance;
|
||||
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() {
|
||||
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 vec4 fragColor;
|
||||
void main() {
|
||||
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
|
||||
discard;
|
||||
}
|
||||
gl_FragColor = fragColor;
|
||||
}`,
|
||||
|
||||
attributes: {
|
||||
position: regl.prop("position"),
|
||||
color: regl.prop("color"),
|
||||
flag: regl.prop("flag")
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
distance: regl.prop("distance"),
|
||||
projView: regl.prop("projView"),
|
||||
nPoints: regl.prop("nPoints"),
|
||||
minViewportDimension: regl.prop("minViewportDimension")
|
||||
},
|
||||
|
||||
count: regl.prop("count"),
|
||||
|
||||
primitive: "points"
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
.graphCanvas path {
|
||||
:local(.graphCanvas) path {
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.axis path,
|
||||
.axis line {
|
||||
:local(.axis) path,
|
||||
:local(.axis) line {
|
||||
fill: none;
|
||||
stroke: #000;
|
||||
stroke-width: 1px;
|
||||
@@ -29,6 +29,6 @@ circle {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
:local(.hidden) {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import * as d3 from "d3";
|
||||
import { connect } from "react-redux";
|
||||
import { mat3, vec2 } from "gl-matrix";
|
||||
import _regl from "regl";
|
||||
import memoize from "memoize-one";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import setupSVGandBrushElements from "./setupSVGandBrush";
|
||||
import setupCentroidSVG from "./setupCentroidSVG";
|
||||
import _camera from "../../util/camera";
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import { isTypedArray } from "../../util/typeHelpers";
|
||||
|
||||
/*
|
||||
Simple 2D transforms control all point painting. There are three:
|
||||
* model - convert from underlying per-point coordinate to a layout.
|
||||
Currently used to move from data to webgl coordinate system.
|
||||
* camera - apply a 2D camera transformation (pan, zoom)
|
||||
* projection - apply any transformation required for screen size and layout
|
||||
*/
|
||||
|
||||
function createProjectionTF(viewportWidth, viewportHeight) {
|
||||
/*
|
||||
the projection transform accounts for the screen size & other layout
|
||||
*/
|
||||
const fractionToUse = 0.95; // fraction of min dimension to use
|
||||
const topGutterSizePx = 32; // toolbar box height
|
||||
const heightMinusGutter = viewportHeight - topGutterSizePx;
|
||||
const minDim = Math.min(viewportWidth, heightMinusGutter);
|
||||
const aspectScale = [
|
||||
(fractionToUse * minDim) / viewportWidth,
|
||||
(fractionToUse * minDim) / viewportHeight
|
||||
];
|
||||
const m = mat3.create();
|
||||
mat3.fromTranslation(m, [
|
||||
0,
|
||||
-topGutterSizePx / viewportHeight / aspectScale[1]
|
||||
]);
|
||||
mat3.scale(m, m, aspectScale);
|
||||
return m;
|
||||
}
|
||||
|
||||
function createModelTF() {
|
||||
/*
|
||||
preallocate coordinate system transformation between data and gl.
|
||||
Data arrives in a [0,1] range, and we operate elsewhere in [-1,1].
|
||||
*/
|
||||
const m = mat3.fromScaling(mat3.create(), [2, 2]);
|
||||
mat3.translate(m, m, [-0.5, -0.5]);
|
||||
return m;
|
||||
}
|
||||
|
||||
function renderThrottle(callback) {
|
||||
/*
|
||||
This wraps a call to requestAnimationFrame(), enforcing a single
|
||||
render callback at any given time (ie, you can call this any number
|
||||
of times, and it will coallesce multiple inter-frame calls into a
|
||||
single render).
|
||||
*/
|
||||
let rafCurrentlyInProgress = null;
|
||||
return function f() {
|
||||
if (rafCurrentlyInProgress) return;
|
||||
const context = this;
|
||||
rafCurrentlyInProgress = window.requestAnimationFrame(() => {
|
||||
callback.apply(context);
|
||||
rafCurrentlyInProgress = null;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@connect(state => ({
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter,
|
||||
responsive: state.responsive,
|
||||
colorRGB: state.colors.rgb,
|
||||
selectionTool: state.graphSelection.tool,
|
||||
currentSelection: state.graphSelection.selection,
|
||||
layoutChoice: state.layoutChoice,
|
||||
centroidLabel: state.centroidLabel,
|
||||
graphInteractionMode: state.controls.graphInteractionMode,
|
||||
colorAccessor: state.colors.colorAccessor
|
||||
}))
|
||||
class Graph extends React.PureComponent {
|
||||
computePointPositions = memoize((X, Y, modelTF) => {
|
||||
/*
|
||||
compute the model coordinate for each point
|
||||
*/
|
||||
const positions = new Float32Array(2 * X.length);
|
||||
for (let i = 0, len = X.length; i < len; i += 1) {
|
||||
const p = vec2.fromValues(X[i], Y[i]);
|
||||
vec2.transformMat3(p, p, modelTF);
|
||||
positions[2 * i] = p[0];
|
||||
positions[2 * i + 1] = p[1];
|
||||
}
|
||||
return positions;
|
||||
});
|
||||
|
||||
computePointColors = memoize(rgb => {
|
||||
/*
|
||||
compute webgl colors for each point
|
||||
*/
|
||||
const colors = new Float32Array(3 * rgb.length);
|
||||
for (let i = 0, len = rgb.length; i < len; i += 1) {
|
||||
colors.set(rgb[i], 3 * i);
|
||||
}
|
||||
return colors;
|
||||
});
|
||||
|
||||
computeSelectedFlags = memoize(
|
||||
(crossfilter, flagSelected, flagUnselected) => {
|
||||
const x = crossfilter.fillByIsSelected(
|
||||
new Float32Array(crossfilter.size()),
|
||||
flagSelected,
|
||||
flagUnselected
|
||||
);
|
||||
return x;
|
||||
}
|
||||
);
|
||||
|
||||
computePointFlags = memoize(
|
||||
(world, crossfilter, colorAccessor, centroidLabel) => {
|
||||
/*
|
||||
We communicate with the shader using three flags:
|
||||
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
|
||||
- isSelected -- the value is selected
|
||||
- isHightlighted -- the value is highlighted in the UI (orthogonal from selection highlighting)
|
||||
|
||||
Due to constraints in webgl vertex shader attributes, these are encoded in a float, "kinda"
|
||||
like bitmasks.
|
||||
|
||||
We also have separate code paths for generating flags for categorical and
|
||||
continuous metadata, as they rely on different tests, and some of the flags
|
||||
(eg, isNaN) are meaningless in the face of categorical metadata.
|
||||
*/
|
||||
|
||||
const flagSelected = 1;
|
||||
const flagNaN = 2;
|
||||
const flagHighlight = 4;
|
||||
|
||||
const flags = this.computeSelectedFlags(
|
||||
crossfilter,
|
||||
flagSelected,
|
||||
0
|
||||
).slice();
|
||||
|
||||
const { metadataField, categoryField } = centroidLabel;
|
||||
const highlightData = metadataField
|
||||
? world.obsAnnotations.col(metadataField)?.asArray()
|
||||
: null;
|
||||
const colorByColumn = colorAccessor
|
||||
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
|
||||
world.varData.col(colorAccessor)?.asArray()
|
||||
: null;
|
||||
const colorByData =
|
||||
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
|
||||
|
||||
if (colorByData || highlightData) {
|
||||
for (let i = 0, len = flags.length; i < len; i += 1) {
|
||||
if (highlightData) {
|
||||
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
|
||||
}
|
||||
if (colorByData) {
|
||||
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.count = 0;
|
||||
this.graphPaddingTop = 0;
|
||||
this.graphPaddingRightLeft = globals.leftSidebarWidth * 2;
|
||||
this.renderCache = {
|
||||
X: null,
|
||||
Y: null,
|
||||
positions: null,
|
||||
colors: null,
|
||||
sizes: null,
|
||||
flags: null
|
||||
};
|
||||
this.state = {
|
||||
toolSVG: null,
|
||||
centroidSVG: null,
|
||||
tool: null,
|
||||
container: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// setup canvas, webgl draw function and camera
|
||||
const camera = _camera(this.reglCanvas);
|
||||
const regl = _regl(this.reglCanvas);
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// preallocate webgl buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const flagBuffer = regl.buffer();
|
||||
|
||||
// create all default rendering transformations
|
||||
const modelTF = createModelTF();
|
||||
const projectionTF = createProjectionTF(
|
||||
this.reglCanvas.width,
|
||||
this.reglCanvas.height
|
||||
);
|
||||
|
||||
// initial draw to canvas
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
);
|
||||
|
||||
this.setState({
|
||||
regl,
|
||||
drawPoints,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
modelTF,
|
||||
modelInvTF: mat3.invert([], modelTF),
|
||||
projectionTF
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { renderCache } = this;
|
||||
const {
|
||||
world,
|
||||
crossfilter,
|
||||
colorRGB,
|
||||
responsive,
|
||||
selectionTool,
|
||||
currentSelection,
|
||||
layoutChoice,
|
||||
graphInteractionMode,
|
||||
colorAccessor,
|
||||
centroidLabel
|
||||
} = this.props;
|
||||
const { regl, toolSVG, centroidSVG } = this.state;
|
||||
let stateChanges = {};
|
||||
|
||||
if (regl && world) {
|
||||
/* update the regl and point rendering state */
|
||||
const { obsLayout, nObs } = world;
|
||||
const {
|
||||
drawPoints,
|
||||
camera,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
flagBuffer,
|
||||
modelTF
|
||||
} = this.state;
|
||||
let { projectionTF } = this.state;
|
||||
let needsRepaint = false;
|
||||
|
||||
if (
|
||||
prevProps.responsive.height !== responsive.height ||
|
||||
prevProps.responsive.width !== responsive.width
|
||||
) {
|
||||
projectionTF = createProjectionTF(
|
||||
this.reglCanvas.width,
|
||||
this.reglCanvas.height
|
||||
);
|
||||
needsRepaint = true;
|
||||
stateChanges = {
|
||||
...stateChanges,
|
||||
projectionTF
|
||||
};
|
||||
}
|
||||
|
||||
/* coordinates for each point */
|
||||
const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
|
||||
const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
|
||||
const newPositions = this.computePointPositions(X, Y, modelTF);
|
||||
if (renderCache.positions !== newPositions) {
|
||||
/* update our cache & GL if the buffer changes */
|
||||
renderCache.positions = newPositions;
|
||||
pointBuffer({ data: newPositions, dimension: 2 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
/* colors for each point */
|
||||
const newColors = this.computePointColors(colorRGB);
|
||||
if (renderCache.colors !== newColors) {
|
||||
/* update our cache & GL if the buffer changes */
|
||||
renderCache.colors = newColors;
|
||||
colorBuffer({ data: newColors, dimension: 3 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
/* flags for each point */
|
||||
const newFlags = this.computePointFlags(
|
||||
world,
|
||||
crossfilter,
|
||||
colorAccessor,
|
||||
centroidLabel
|
||||
);
|
||||
if (renderCache.flags !== newFlags) {
|
||||
renderCache.flags = newFlags;
|
||||
flagBuffer({ data: newFlags, dimension: 1 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
this.count = nObs;
|
||||
|
||||
if (needsRepaint) {
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Centroid SVG creation is disabled for now but should go into the
|
||||
// first and third cases if enabled
|
||||
if (
|
||||
prevProps.responsive.height !== responsive.height ||
|
||||
prevProps.responsive.width !== responsive.width
|
||||
) {
|
||||
// If the window size has changed we want to recreate all SVGs
|
||||
stateChanges = { ...stateChanges, ...this.createToolSVG() };
|
||||
} else if (
|
||||
(responsive.height && responsive.width && !toolSVG) ||
|
||||
selectionTool !== prevProps.selectionTool ||
|
||||
prevProps.graphInteractionMode !== graphInteractionMode
|
||||
) {
|
||||
// first time or change of selection tool6
|
||||
stateChanges = { ...stateChanges, ...this.createToolSVG() };
|
||||
} else if (
|
||||
centroidLabel !== prevProps.centroidLabel ||
|
||||
(responsive.height && responsive.width && !centroidSVG)
|
||||
) {
|
||||
// First time for centroid or label change
|
||||
}
|
||||
|
||||
/*
|
||||
if the selection tool or state has changed, ensure that the selection
|
||||
tool correctly reflects the underlying selection.
|
||||
*/
|
||||
if (
|
||||
currentSelection !== prevProps.currentSelection ||
|
||||
graphInteractionMode !== prevProps.graphInteractionMode ||
|
||||
stateChanges.toolSVG
|
||||
) {
|
||||
const { tool, container } = this.state;
|
||||
this.selectionToolUpdate(
|
||||
stateChanges.tool ? stateChanges.tool : tool,
|
||||
stateChanges.container ? stateChanges.container : container
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(stateChanges).length > 0) {
|
||||
this.setState(stateChanges);
|
||||
}
|
||||
}
|
||||
|
||||
handleCanvasEvent = e => {
|
||||
const { camera, projectionTF } = this.state;
|
||||
if (e.type !== "wheel") e.preventDefault();
|
||||
if (camera.handleEvent(e, projectionTF)) {
|
||||
this.renderCanvas();
|
||||
}
|
||||
};
|
||||
|
||||
createToolSVG() {
|
||||
/*
|
||||
Called from componentDidUpdate. Create the tool SVG, and return any
|
||||
state changes that should be passed to setState().
|
||||
*/
|
||||
const { responsive, selectionTool, graphInteractionMode } = this.props;
|
||||
|
||||
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
|
||||
d3.select("#graphAttachPoint")
|
||||
.select("#tool")
|
||||
.remove();
|
||||
|
||||
let handleStart;
|
||||
let handleDrag;
|
||||
let handleEnd;
|
||||
let handleCancel;
|
||||
if (selectionTool === "brush") {
|
||||
handleStart = this.handleBrushStartAction.bind(this);
|
||||
handleDrag = this.handleBrushDragAction.bind(this);
|
||||
handleEnd = this.handleBrushEndAction.bind(this);
|
||||
} else {
|
||||
handleStart = this.handleLassoStart.bind(this);
|
||||
handleEnd = this.handleLassoEnd.bind(this);
|
||||
handleCancel = this.handleLassoCancel.bind(this);
|
||||
}
|
||||
|
||||
const { svg: newToolSVG, tool, container } = setupSVGandBrushElements(
|
||||
selectionTool,
|
||||
handleStart,
|
||||
handleDrag,
|
||||
handleEnd,
|
||||
handleCancel,
|
||||
responsive,
|
||||
this.graphPaddingRightLeft,
|
||||
graphInteractionMode
|
||||
);
|
||||
|
||||
return { toolSVG: newToolSVG, tool, container };
|
||||
}
|
||||
|
||||
createCentroidSVG() {
|
||||
/*
|
||||
Called from componentDidUpdate. Create the centroid SVG, and return any
|
||||
state changes that should be passed to setState().
|
||||
|
||||
CURRENTLY UNUSED
|
||||
*/
|
||||
const { responsive, centroidLabel, colorAccessor } = this.props;
|
||||
d3.select("#graphAttachPoint")
|
||||
.select("#centroid-container")
|
||||
.remove();
|
||||
|
||||
if (centroidLabel.metadataField === "" || !centroidLabel.centroidXY) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const centroidScreen = this.mapPointToScreen(centroidLabel.centroidXY);
|
||||
|
||||
const newCentroidSVG = setupCentroidSVG(
|
||||
responsive,
|
||||
this.graphPaddingRightLeft,
|
||||
centroidScreen,
|
||||
centroidLabel.categoryField,
|
||||
colorAccessor
|
||||
);
|
||||
|
||||
return { centroidSVG: newCentroidSVG };
|
||||
}
|
||||
|
||||
brushToolUpdate(tool, container) {
|
||||
/*
|
||||
this is called from componentDidUpdate(), so be very careful using
|
||||
anything from this.state, which may be updated asynchronously.
|
||||
*/
|
||||
const { currentSelection } = this.props;
|
||||
if (container) {
|
||||
const toolCurrentSelection = d3.brushSelection(container.node());
|
||||
|
||||
if (currentSelection.mode === "within-rect") {
|
||||
/*
|
||||
if there is a selection, make sure the brush tool matches
|
||||
*/
|
||||
const screenCoords = [
|
||||
this.mapPointToScreen(currentSelection.brushCoords.northwest),
|
||||
this.mapPointToScreen(currentSelection.brushCoords.southeast)
|
||||
];
|
||||
if (!toolCurrentSelection) {
|
||||
/* tool is not selected, so just move the brush */
|
||||
container.call(tool.move, screenCoords);
|
||||
} else {
|
||||
/* there is an active selection and a brush - make sure they match */
|
||||
/* this just sums the difference of each dimension, of each point */
|
||||
let delta = 0;
|
||||
for (let x = 0; x < 2; x += 1) {
|
||||
for (let y = 0; y < 2; y += 1) {
|
||||
delta += Math.abs(
|
||||
screenCoords[x][y] - toolCurrentSelection[x][y]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (delta > 0) {
|
||||
container.call(tool.move, screenCoords);
|
||||
}
|
||||
}
|
||||
} else if (toolCurrentSelection) {
|
||||
/* no selection, so clear the brush tool if it is set */
|
||||
container.call(tool.move, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lassoToolUpdate(tool) {
|
||||
/*
|
||||
this is called from componentDidUpdate(), so be very careful using
|
||||
anything from this.state, which may be updated asynchronously.
|
||||
*/
|
||||
const { currentSelection } = this.props;
|
||||
if (currentSelection.mode === "within-polygon") {
|
||||
/*
|
||||
if there is a current selection, make sure the lasso tool matches
|
||||
*/
|
||||
const polygon = currentSelection.polygon.map(p =>
|
||||
this.mapPointToScreen(p)
|
||||
);
|
||||
tool.move(polygon);
|
||||
} else {
|
||||
tool.reset();
|
||||
}
|
||||
}
|
||||
|
||||
selectionToolUpdate(tool, container) {
|
||||
/*
|
||||
this is called from componentDidUpdate(), so be very careful using
|
||||
anything from this.state, which may be updated asynchronously.
|
||||
*/
|
||||
const { selectionTool } = this.props;
|
||||
switch (selectionTool) {
|
||||
case "brush":
|
||||
this.brushToolUpdate(tool, container);
|
||||
break;
|
||||
case "lasso":
|
||||
this.lassoToolUpdate(tool, container);
|
||||
break;
|
||||
default:
|
||||
/* punt? */
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
mapScreenToPoint(pin) {
|
||||
/*
|
||||
Map an XY coordinates from screen domain to cell/point range,
|
||||
accounting for current pan/zoom camera.
|
||||
*/
|
||||
|
||||
const { responsive } = this.props;
|
||||
const { camera, projectionTF, modelInvTF } = this.state;
|
||||
const cameraInvTF = camera.invView();
|
||||
|
||||
/* screen -> gl */
|
||||
const x =
|
||||
(2 * pin[0]) / (responsive.width - this.graphPaddingRightLeft) - 1;
|
||||
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
|
||||
|
||||
const xy = vec2.fromValues(x, y);
|
||||
const projectionInvTF = mat3.invert(mat3.create(), projectionTF);
|
||||
vec2.transformMat3(xy, xy, projectionInvTF);
|
||||
vec2.transformMat3(xy, xy, cameraInvTF);
|
||||
vec2.transformMat3(xy, xy, modelInvTF);
|
||||
return xy;
|
||||
}
|
||||
|
||||
mapPointToScreen(xyCell) {
|
||||
/*
|
||||
Map an XY coordinate from cell/point domain to screen range. Inverse
|
||||
of mapScreenToPoint()
|
||||
*/
|
||||
|
||||
const { responsive } = this.props;
|
||||
const { camera, projectionTF, modelTF } = this.state;
|
||||
const cameraTF = camera.view();
|
||||
|
||||
const xy = vec2.transformMat3(vec2.create(), xyCell, modelTF);
|
||||
vec2.transformMat3(xy, xy, cameraTF);
|
||||
vec2.transformMat3(xy, xy, projectionTF);
|
||||
|
||||
const pin = [
|
||||
Math.round(
|
||||
((xy[0] + 1) * (responsive.width - this.graphPaddingRightLeft)) / 2
|
||||
),
|
||||
Math.round(
|
||||
-((xy[1] + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop)
|
||||
)
|
||||
];
|
||||
return pin;
|
||||
}
|
||||
|
||||
handleBrushDragAction() {
|
||||
/*
|
||||
event describing brush position:
|
||||
@-------|
|
||||
| |
|
||||
| |
|
||||
|-------@
|
||||
*/
|
||||
// ignore programatically generated events
|
||||
if (d3.event.sourceEvent === null || !d3.event.selection) return;
|
||||
|
||||
const { dispatch } = this.props;
|
||||
const s = d3.event.selection;
|
||||
const brushCoords = {
|
||||
northwest: this.mapScreenToPoint([s[0][0], s[0][1]]),
|
||||
southeast: this.mapScreenToPoint([s[1][0], s[1][1]])
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: "graph brush change",
|
||||
brushCoords
|
||||
});
|
||||
}
|
||||
|
||||
handleBrushStartAction() {
|
||||
// Ignore programatically generated events.
|
||||
if (!d3.event.sourceEvent) return;
|
||||
|
||||
const { dispatch } = this.props;
|
||||
dispatch({ type: "graph brush start" });
|
||||
}
|
||||
|
||||
handleBrushEndAction() {
|
||||
// Ignore programatically generated events.
|
||||
if (!d3.event.sourceEvent) return;
|
||||
|
||||
/*
|
||||
coordinates will be included if selection made, null
|
||||
if selection cleared.
|
||||
*/
|
||||
const { dispatch } = this.props;
|
||||
const s = d3.event.selection;
|
||||
if (s) {
|
||||
const brushCoords = {
|
||||
northwest: this.mapScreenToPoint(s[0]),
|
||||
southeast: this.mapScreenToPoint(s[1])
|
||||
};
|
||||
dispatch({
|
||||
type: "graph brush end",
|
||||
brushCoords
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "graph brush deselect"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleBrushDeselectAction() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "graph brush deselect"
|
||||
});
|
||||
}
|
||||
|
||||
handleLassoStart() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "graph lasso start"
|
||||
});
|
||||
}
|
||||
|
||||
// when a lasso is completed, filter to the points within the lasso polygon
|
||||
handleLassoEnd(polygon) {
|
||||
const minimumPolygonArea = 10;
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (
|
||||
polygon.length < 3 ||
|
||||
Math.abs(d3.polygonArea(polygon)) < minimumPolygonArea
|
||||
) {
|
||||
// if less than three points, or super small area, treat as a clear selection.
|
||||
dispatch({ type: "graph lasso deselect" });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "graph lasso end",
|
||||
polygon: polygon.map(xy => this.mapScreenToPoint(xy)) // transform the polygon
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleLassoCancel() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({ type: "graph lasso cancel" });
|
||||
}
|
||||
|
||||
handleLassoDeselectAction() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({ type: "graph lasso deselect" });
|
||||
}
|
||||
|
||||
handleDeselectAction() {
|
||||
const { selectionTool } = this.props;
|
||||
if (selectionTool === "brush") this.handleBrushDeselectAction();
|
||||
if (selectionTool === "lasso") this.handleLassoDeselectAction();
|
||||
}
|
||||
|
||||
handleOpacityRangeChange(e) {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "change opacity deselected cells in 2d graph background",
|
||||
data: e.target.value
|
||||
});
|
||||
}
|
||||
|
||||
renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
) {
|
||||
const { universe } = this.props;
|
||||
if (!this.reglCanvas || !universe) return;
|
||||
const cameraTF = camera.view();
|
||||
const projView = mat3.multiply(mat3.create(), projectionTF, cameraTF);
|
||||
const { width, height } = this.reglCanvas;
|
||||
regl.poll();
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1]
|
||||
});
|
||||
drawPoints({
|
||||
distance: camera.distance(),
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
flag: flagBuffer,
|
||||
count: this.count,
|
||||
projView,
|
||||
nPoints: universe.nObs,
|
||||
minViewportDimension: Math.min(width || 800, height || 600)
|
||||
});
|
||||
regl._gl.flush();
|
||||
}
|
||||
|
||||
renderCanvas = renderThrottle(() => {
|
||||
const {
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
} = this.state;
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
);
|
||||
});
|
||||
|
||||
render() {
|
||||
const { responsive } = this.props;
|
||||
|
||||
return (
|
||||
<div id="graphWrapper">
|
||||
<div
|
||||
style={{
|
||||
zIndex: -9999,
|
||||
position: "fixed",
|
||||
top: this.graphPaddingTop,
|
||||
right: globals.leftSidebarWidth
|
||||
}}
|
||||
>
|
||||
<div id="graphAttachPoint" />
|
||||
<div style={{ padding: 0, margin: 0 }}>
|
||||
<canvas
|
||||
width={responsive.width - this.graphPaddingRightLeft}
|
||||
height={responsive.height - this.graphPaddingTop}
|
||||
data-testid="layout-graph"
|
||||
ref={canvas => {
|
||||
this.reglCanvas = canvas;
|
||||
}}
|
||||
onMouseDown={this.handleCanvasEvent}
|
||||
onMouseUp={this.handleCanvasEvent}
|
||||
onMouseMove={this.handleCanvasEvent}
|
||||
onDoubleClick={this.handleCanvasEvent}
|
||||
onWheel={this.handleCanvasEvent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Graph;
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as d3 from "d3";
|
||||
import styles from "./graph.css";
|
||||
|
||||
export default (responsive, graphPaddingRight, xy, text, colorBy) => {
|
||||
const containerWidth = responsive.width - graphPaddingRight;
|
||||
|
||||
const svg = d3
|
||||
.select("#graphAttachPoint")
|
||||
.append("svg")
|
||||
.attr("id", "centroid-container")
|
||||
.attr("data-testid", "centroid-overlay")
|
||||
.attr("width", containerWidth)
|
||||
.attr("height", responsive.height)
|
||||
.attr("class", `${styles.graphSVG}`)
|
||||
.style("z-index", 998)
|
||||
.style("pointer-events", "none");
|
||||
// TODO: Create own styles, ask Colin for an explanation on the css
|
||||
// For now I'm going to put centroid z-index at 998 and lasso on 999
|
||||
|
||||
const label = svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(${xy[0]}, ${xy[1]})`);
|
||||
|
||||
label
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.text(text)
|
||||
.style("font-family", "Roboto Condensed")
|
||||
.style("font-size", "18px")
|
||||
.style("font-weight", "700")
|
||||
.style("fill", colorBy ? "black" : "rgb(32, 178, 212)");
|
||||
|
||||
return svg;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,66 @@
|
||||
// jshint esversion: 6
|
||||
import * as d3 from "d3";
|
||||
import styles from "./graph.css";
|
||||
import Lasso from "./setupLasso";
|
||||
|
||||
/******************************************
|
||||
*******************************************
|
||||
put svg & brush in DOM
|
||||
*******************************************
|
||||
******************************************/
|
||||
|
||||
export default (
|
||||
selectionToolType,
|
||||
handleStartAction,
|
||||
handleDragAction,
|
||||
handleEndAction,
|
||||
handleCancelAction,
|
||||
responsive,
|
||||
graphPaddingRight,
|
||||
graphInteractionMode
|
||||
) => {
|
||||
const svg = d3
|
||||
.select("#graphAttachPoint")
|
||||
.append("svg")
|
||||
.attr("id", "tool")
|
||||
.attr("data-testid", "layout-overlay")
|
||||
.attr("width", responsive.width - graphPaddingRight)
|
||||
.attr("height", responsive.height)
|
||||
.attr("class", `${styles.graphSVG}`)
|
||||
.style("z-index", 999)
|
||||
.style("display", graphInteractionMode === "select" ? "inherit" : "none");
|
||||
|
||||
if (selectionToolType === "brush") {
|
||||
const brush = d3
|
||||
.brush()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[responsive.width - graphPaddingRight, responsive.height]
|
||||
])
|
||||
.on("start", handleStartAction)
|
||||
.on("brush", handleDragAction)
|
||||
// FYI, brush doesn't generate cancel
|
||||
.on("end", handleEndAction);
|
||||
|
||||
const brushContainer = svg
|
||||
.append("g")
|
||||
.attr("class", "graph_brush")
|
||||
.call(brush);
|
||||
|
||||
return { svg, container: brushContainer, tool: brush };
|
||||
}
|
||||
|
||||
if (selectionToolType === "lasso") {
|
||||
const lasso = Lasso()
|
||||
.on("end", handleEndAction)
|
||||
// FYI, Lasso doesn't generate drag
|
||||
.on("start", handleStartAction)
|
||||
.on("cancel", handleCancelAction);
|
||||
|
||||
const lassoContainer = svg.call(lasso);
|
||||
|
||||
return { svg, container: lassoContainer, tool: lasso };
|
||||
}
|
||||
|
||||
throw new Error("unknown graph selection tool");
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import Categorical from "../categorical/categorical";
|
||||
import * as globals from "../../globals";
|
||||
import DynamicScatterplot from "../scatterplot/scatterplot";
|
||||
import TopLeftLogoAndTitle from "./topLeftLogoAndTitle";
|
||||
|
||||
@connect(state => ({
|
||||
responsive: state.responsive,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
|
||||
}))
|
||||
class LeftSideBar extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
responsive,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor
|
||||
} = this.props;
|
||||
|
||||
/*
|
||||
this magic number should be made less fragile,
|
||||
if cellxgene logo or tabs change, this must as well
|
||||
*/
|
||||
const logoRelatedPadding = 50;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
backgroundColor: "white",
|
||||
/* x y blur spread color */
|
||||
borderRight: `1px solid ${globals.lightGrey}`
|
||||
}}
|
||||
>
|
||||
<TopLeftLogoAndTitle />
|
||||
<div
|
||||
style={{
|
||||
height: responsive.height - logoRelatedPadding,
|
||||
marginTop: logoRelatedPadding,
|
||||
width: globals.leftSidebarWidth,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden"
|
||||
}}
|
||||
>
|
||||
<Categorical />
|
||||
</div>
|
||||
{scatterplotXXaccessor && scatterplotYYaccessor ? (
|
||||
<DynamicScatterplot />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LeftSideBar;
|
||||
@@ -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={{
|
||||
marginLeft: 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"
|
||||
className="bp3-button"
|
||||
disabled={isClipDisabled()}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
marginRight: 5,
|
||||
marginLeft: 5
|
||||
}}
|
||||
onClick={handleClipCommit}
|
||||
>
|
||||
Clip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Clip;
|
||||
@@ -0,0 +1,502 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
Button,
|
||||
AnchorButton,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Position,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Icon
|
||||
} from "@blueprintjs/core";
|
||||
import { World } from "../../util/stateManager";
|
||||
import actions from "../../actions";
|
||||
import CellSetButton from "./cellSetButtons";
|
||||
import InformationMenu from "./infoMenu";
|
||||
import UndoRedoReset from "./undoRedoReset";
|
||||
import Clip from "./clip";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
@connect(state => ({
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
loading: state.controls.loading,
|
||||
crossfilter: state.crossfilter,
|
||||
differential: state.differential,
|
||||
resettingInterface: state.controls.resettingInterface,
|
||||
layoutChoice: state.layoutChoice,
|
||||
graphInteractionMode: state.controls.graphInteractionMode,
|
||||
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
|
||||
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
diffexpGenes: state.controls.diffexpGenes,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
celllist1: state.differential.celllist1,
|
||||
celllist2: state.differential.celllist2,
|
||||
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase
|
||||
undoDisabled: state["@@undoable/past"].length === 0,
|
||||
redoDisabled: state["@@undoable/future"].length === 0,
|
||||
aboutLink: state.config?.links?.["about-dataset"],
|
||||
disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false,
|
||||
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false
|
||||
}))
|
||||
class MenuBar extends React.Component {
|
||||
static isValidDigitKeyEvent(e) {
|
||||
/*
|
||||
Return true if this event is necessary to enter a percent number input.
|
||||
Return false if not.
|
||||
|
||||
Returns true for events with keys: backspace, control, alt, meta, [0-9],
|
||||
or events that don't have a key.
|
||||
*/
|
||||
if (e.key === null) return true;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return true;
|
||||
|
||||
// concept borrowed from blueprint's numericInputUtils:
|
||||
// keys that print a single character when pressed have a `key` name of
|
||||
// length 1. every other key has a longer `key` name (e.g. "Backspace",
|
||||
// "ArrowUp", "Shift"). since none of those keys can print a character
|
||||
// to the field--and since they may have important native behaviors
|
||||
// beyond printing a character--we don't want to disable their effects.
|
||||
const isSingleCharKey = e.key.length === 1;
|
||||
if (!isSingleCharKey) return true;
|
||||
|
||||
const key = e.key.charCodeAt(0) - 48; /* "0" */
|
||||
return key >= 0 && key <= 9;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
pendingClipPercentiles: null
|
||||
};
|
||||
}
|
||||
|
||||
isClipDisabled = () => {
|
||||
/*
|
||||
return true if clip button should be disabled.
|
||||
*/
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
|
||||
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
|
||||
|
||||
const { world } = this.props;
|
||||
const currentClipMin = 100 * world?.clipQuantiles?.min;
|
||||
const currentClipMax = 100 * world?.clipQuantiles?.max;
|
||||
|
||||
// if you change this test, be careful with logic around
|
||||
// comparisons between undefined / NaN handling.
|
||||
const isDisabled =
|
||||
!(clipPercentileMin < clipPercentileMax) ||
|
||||
(clipPercentileMin === currentClipMin &&
|
||||
clipPercentileMax === currentClipMax);
|
||||
|
||||
return isDisabled;
|
||||
};
|
||||
|
||||
isResetDisabled = () => {
|
||||
/*
|
||||
Reset should be disabled when all of the following are true:
|
||||
* nothing is selected in the crossfilter
|
||||
* world EQ universe
|
||||
* nothing is colored by
|
||||
* there are no userDefinedGenes or diffexpGenes displayed
|
||||
* scatterplot is not displayed
|
||||
* nothing in cellset1 or cellset2
|
||||
* clip percentiles are [0,100]
|
||||
*/
|
||||
const {
|
||||
crossfilter,
|
||||
world,
|
||||
universe,
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
colorAccessor,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
celllist1,
|
||||
celllist2,
|
||||
clipPercentileMin,
|
||||
clipPercentileMax
|
||||
} = this.props;
|
||||
|
||||
if (!crossfilter || !world || !universe) {
|
||||
return false;
|
||||
}
|
||||
const nothingSelected = crossfilter.countSelected() === crossfilter.size();
|
||||
const nothingColoredBy = !colorAccessor;
|
||||
const noGenes = userDefinedGenes.length === 0 && diffexpGenes.length === 0;
|
||||
const scatterNotDpl = !scatterplotXXaccessor || !scatterplotYYaccessor;
|
||||
const nothingInCellsets = !celllist1 && !celllist2;
|
||||
|
||||
return (
|
||||
nothingSelected &&
|
||||
World.worldEqUniverse(world, universe) &&
|
||||
nothingColoredBy &&
|
||||
noGenes &&
|
||||
scatterNotDpl &&
|
||||
nothingInCellsets &&
|
||||
clipPercentileMax === 100 &&
|
||||
clipPercentileMin === 0
|
||||
);
|
||||
};
|
||||
|
||||
resetInterface = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "interface reset started"
|
||||
});
|
||||
dispatch(actions.resetInterface());
|
||||
};
|
||||
|
||||
handleClipOnKeyPress = e => {
|
||||
/*
|
||||
allow only numbers, plus other critical keys which
|
||||
may be required to make a number
|
||||
*/
|
||||
if (!MenuBar.isValidDigitKeyEvent(e)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
handleClipPercentileMinValueChange = v => {
|
||||
/*
|
||||
Ignore anything that isn't a legit number
|
||||
*/
|
||||
if (!Number.isFinite(v)) return;
|
||||
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
|
||||
|
||||
/*
|
||||
clamp to [0, currentClipPercentileMax]
|
||||
*/
|
||||
if (v <= 0) v = 0;
|
||||
if (v > 100) v = 100;
|
||||
const clipPercentileMin = Math.round(v); // paranoia
|
||||
this.setState({
|
||||
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
|
||||
});
|
||||
};
|
||||
|
||||
handleClipPercentileMaxValueChange = v => {
|
||||
/*
|
||||
Ignore anything that isn't a legit number
|
||||
*/
|
||||
if (!Number.isFinite(v)) return;
|
||||
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
|
||||
|
||||
/*
|
||||
clamp to [0, 100]
|
||||
*/
|
||||
if (v < 0) v = 0;
|
||||
if (v > 100) v = 100;
|
||||
const clipPercentileMax = Math.round(v); // paranoia
|
||||
|
||||
this.setState({
|
||||
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
|
||||
});
|
||||
};
|
||||
|
||||
handleClipCommit = () => {
|
||||
const { dispatch } = this.props;
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
|
||||
const min = clipPercentileMin / 100;
|
||||
const max = clipPercentileMax / 100;
|
||||
dispatch({
|
||||
type: "set clip quantiles",
|
||||
clipQuantiles: { min, max }
|
||||
});
|
||||
};
|
||||
|
||||
handleClipOpening = () => {
|
||||
const { clipPercentileMin, clipPercentileMax } = this.props;
|
||||
this.setState({
|
||||
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
|
||||
});
|
||||
};
|
||||
|
||||
handleClipClosing = () => {
|
||||
this.setState({ pendingClipPercentiles: null });
|
||||
};
|
||||
|
||||
handleLayoutChoiceChange = e => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "set layout choice",
|
||||
layoutChoice: e.currentTarget.value
|
||||
});
|
||||
};
|
||||
|
||||
computeDiffExp = () => {
|
||||
const { dispatch, differential } = this.props;
|
||||
if (differential.celllist1 && differential.celllist2) {
|
||||
dispatch(
|
||||
actions.requestDifferentialExpression(
|
||||
differential.celllist1,
|
||||
differential.celllist2
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
clearDifferentialExpression = () => {
|
||||
const { dispatch, differential } = this.props;
|
||||
dispatch({
|
||||
type: "clear differential expression",
|
||||
diffExp: differential.diffExp
|
||||
});
|
||||
dispatch({
|
||||
type: "clear scatterplot"
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="bp3-button-group" style={{ marginRight: 10 }}>
|
||||
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
|
||||
<CellSetButton {...this.props} 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
|
||||
type="button"
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatch,
|
||||
crossfilter,
|
||||
resettingInterface,
|
||||
libraryVersions,
|
||||
undoDisabled,
|
||||
redoDisabled,
|
||||
selectionTool,
|
||||
clipPercentileMin,
|
||||
clipPercentileMax,
|
||||
layoutChoice,
|
||||
graphInteractionMode,
|
||||
aboutLink
|
||||
} = this.props;
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
|
||||
// constants used to create selection tool button
|
||||
let selectionTooltip;
|
||||
let selectionButtonClass;
|
||||
if (selectionTool === "brush") {
|
||||
selectionTooltip = "Brush selection";
|
||||
selectionButtonClass = "bp3-icon-select";
|
||||
} else {
|
||||
selectionTooltip = "Lasso selection";
|
||||
selectionButtonClass = "bp3-icon-polygon-filter";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: globals.leftSidebarWidth + 8,
|
||||
top: 8
|
||||
}}
|
||||
>
|
||||
{this.renderDiffExp()}
|
||||
<Tooltip
|
||||
content="Show only metadata and cells which are currently selected"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
data-testid="subset-button"
|
||||
disabled={
|
||||
crossfilter &&
|
||||
(crossfilter.countSelected() === 0 ||
|
||||
crossfilter.countSelected() === crossfilter.size())
|
||||
}
|
||||
style={{
|
||||
marginRight: 10
|
||||
}}
|
||||
onClick={() => {
|
||||
dispatch(actions.regraph());
|
||||
dispatch({ type: "increment graph render counter" });
|
||||
}}
|
||||
>
|
||||
<Icon icon="double-chevron-down" />
|
||||
</AnchorButton>
|
||||
</Tooltip>
|
||||
<div className="bp3-button-group">
|
||||
<Tooltip
|
||||
content={selectionTooltip}
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="mode-lasso"
|
||||
className={`bp3-button ${selectionButtonClass}`}
|
||||
active={graphInteractionMode === "select"}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "change graph interaction mode",
|
||||
data: "select"
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content="Drag to pan, scroll to zoom"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="mode-pan-zoom"
|
||||
className="bp3-button bp3-icon-zoom-in"
|
||||
active={graphInteractionMode === "zoom"}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "change graph interaction mode",
|
||||
data: "zoom"
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className="bp3-button-group"
|
||||
style={{
|
||||
marginLeft: 10
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
target={
|
||||
<Tooltip
|
||||
content="Select embedding for visualization"
|
||||
position="bottom"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="layout-choice"
|
||||
className="bp3-button bp3-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>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Clip
|
||||
pendingClipPercentiles={pendingClipPercentiles}
|
||||
clipPercentileMin={clipPercentileMin}
|
||||
clipPercentileMax={clipPercentileMax}
|
||||
handleClipOpening={this.handleClipOpening}
|
||||
handleClipClosing={this.handleClipClosing}
|
||||
handleClipCommit={this.handleClipCommit}
|
||||
isClipDisabled={this.isClipDisabled}
|
||||
handleClipOnKeyPress={this.handleClipOnKeyPress}
|
||||
handleClipPercentileMaxValueChange={
|
||||
this.handleClipPercentileMaxValueChange
|
||||
}
|
||||
handleClipPercentileMinValueChange={
|
||||
this.handleClipPercentileMinValueChange
|
||||
}
|
||||
/>
|
||||
<UndoRedoReset
|
||||
dispatch={dispatch}
|
||||
isResetDisabled={this.isResetDisabled}
|
||||
resetInterface={this.resetInterface}
|
||||
resettingInterface={resettingInterface}
|
||||
undoDisabled={undoDisabled}
|
||||
redoDisabled={redoDisabled}
|
||||
/>
|
||||
<InformationMenu
|
||||
libraryVersions={libraryVersions}
|
||||
aboutLink={aboutLink}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MenuBar;
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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={{ marginLeft: 10 }} 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/faq.html"
|
||||
target="_blank"
|
||||
icon="help"
|
||||
text="FAQ"
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://join-cellxgene-users.herokuapp.com/"
|
||||
target="_blank"
|
||||
icon="chat"
|
||||
text="Chat"
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://chanzuckerberg.github.io/cellxgene/"
|
||||
target="_blank"
|
||||
icon="book"
|
||||
text="Docs"
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://github.com/chanzuckerberg/cellxgene"
|
||||
target="_blank"
|
||||
icon="git-branch"
|
||||
text="Github"
|
||||
/>
|
||||
<MenuItem
|
||||
target="_blank"
|
||||
text={`cellxgene v${
|
||||
libraryVersions && libraryVersions.cellxgene
|
||||
? libraryVersions.cellxgene
|
||||
: null
|
||||
}`}
|
||||
/>
|
||||
<MenuItem text="MIT License" />
|
||||
</Menu>
|
||||
}
|
||||
position={Position.BOTTOM_RIGHT}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="bp3-button bp3-icon-info-sign"
|
||||
style={{
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InformationMenu;
|
||||
@@ -0,0 +1,72 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
import { tooltipHoverOpenDelay } from "../../globals";
|
||||
|
||||
function InformationMenu(props) {
|
||||
const {
|
||||
resettingInterface,
|
||||
undoDisabled,
|
||||
redoDisabled,
|
||||
resetInterface,
|
||||
isResetDisabled,
|
||||
dispatch
|
||||
} = props;
|
||||
return (
|
||||
<div style={{ marginLeft: 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"
|
||||
}}
|
||||
/>
|
||||
</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"
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content="Reset cellxgene, clearing all selections"
|
||||
position="bottom"
|
||||
hoverOpenDelay={tooltipHoverOpenDelay}
|
||||
>
|
||||
<AnchorButton
|
||||
disabled={isResetDisabled()}
|
||||
style={{ marginLeft: 10 }}
|
||||
type="button"
|
||||
loading={resettingInterface}
|
||||
intent="none"
|
||||
icon="refresh"
|
||||
onClick={resetInterface}
|
||||
data-testid="reset"
|
||||
data-testclass={`resetting-${resettingInterface}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InformationMenu;
|
||||
@@ -0,0 +1,43 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import Continuous from "../continuous/continuous";
|
||||
import GeneExpression from "../geneExpression";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
@connect(state => ({
|
||||
responsive: state.responsive,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
|
||||
}))
|
||||
class RightSidebar extends React.Component {
|
||||
render() {
|
||||
const { responsive } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: 0,
|
||||
backgroundColor: "white",
|
||||
/* x y blur spread color */
|
||||
borderLeft: `1px solid ${globals.lightGrey}`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: responsive.height,
|
||||
width: globals.leftSidebarWidth,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden"
|
||||
}}
|
||||
>
|
||||
<GeneExpression />
|
||||
<Continuous />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RightSidebar;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { glPointFlags, glPointSize } from "../../util/glHelpers";
|
||||
|
||||
export default function(regl) {
|
||||
return regl({
|
||||
vert: `
|
||||
precision mediump float;
|
||||
|
||||
attribute vec2 position;
|
||||
attribute vec3 color;
|
||||
attribute float flag;
|
||||
|
||||
uniform mat3 projection;
|
||||
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() {
|
||||
bool isNaN, isSelected, isHighlight;
|
||||
getFlags(flag, isNaN, isSelected, isHighlight);
|
||||
|
||||
gl_PointSize = pointSize(nPoints, minViewportDimension, isSelected, isHighlight);
|
||||
|
||||
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
|
||||
vec3 xy = projection * 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 vec4 fragColor;
|
||||
void main() {
|
||||
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
|
||||
discard;
|
||||
}
|
||||
gl_FragColor = fragColor;
|
||||
}`,
|
||||
|
||||
attributes: {
|
||||
position: regl.prop("position"),
|
||||
color: regl.prop("color"),
|
||||
flag: regl.prop("flag")
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
projection: regl.prop("projection"),
|
||||
nPoints: regl.prop("nPoints"),
|
||||
minViewportDimension: regl.prop("minViewportDimension")
|
||||
},
|
||||
|
||||
count: regl.prop("count"),
|
||||
|
||||
primitive: "points"
|
||||
});
|
||||
}
|
||||
+4
-5
@@ -1,15 +1,14 @@
|
||||
|
||||
.scatterplot {
|
||||
:local(.scatterplot) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scatterplot svg,
|
||||
.scatterplot canvas {
|
||||
:local(.scatterplot) svg,
|
||||
:local(.scatterplot) canvas {
|
||||
font: 10px sans-serif;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.scatterplot canvas {
|
||||
:local(.scatterplot) canvas {
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Button, ButtonGroup } from "@blueprintjs/core";
|
||||
import _regl from "regl";
|
||||
import * as d3 from "d3";
|
||||
import { mat3 } from "gl-matrix";
|
||||
import memoize from "memoize-one";
|
||||
import { isTypedArray } from "../../util/typeHelpers";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import setupScatterplot from "./setupScatterplot";
|
||||
import styles from "./scatterplot.css";
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import { margin, width, height } from "./util";
|
||||
import finiteExtent from "../../util/finiteExtent";
|
||||
|
||||
function createProjectionTF(viewportWidth, viewportHeight) {
|
||||
/*
|
||||
the projection transform accounts for the screen size & other layout
|
||||
*/
|
||||
const m = mat3.create();
|
||||
return mat3.projection(m, viewportWidth, viewportHeight);
|
||||
}
|
||||
|
||||
@connect(state => {
|
||||
const { world, crossfilter, universe } = state;
|
||||
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
|
||||
const expressionX = scatterplotXXaccessor
|
||||
? world.varData.col(scatterplotXXaccessor)?.asArray()
|
||||
: null;
|
||||
const expressionY = scatterplotYYaccessor
|
||||
? world.varData.col(scatterplotYYaccessor)?.asArray()
|
||||
: null;
|
||||
|
||||
return {
|
||||
world,
|
||||
universe,
|
||||
|
||||
colorRGB: state.colors.rgb,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
|
||||
centroidLabel: state.centroidLabel,
|
||||
|
||||
// Accessors are var/gene names (strings)
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
|
||||
|
||||
differential: state.differential,
|
||||
|
||||
expressionX,
|
||||
expressionY,
|
||||
|
||||
crossfilter,
|
||||
|
||||
responsive: state.responsive
|
||||
};
|
||||
})
|
||||
class Scatterplot extends React.PureComponent {
|
||||
computePointPositions = memoize((X, Y, xScale, yScale) => {
|
||||
const positions = new Float32Array(2 * X.length);
|
||||
for (let i = 0, len = X.length; i < len; i += 1) {
|
||||
positions[2 * i] = xScale(X[i]);
|
||||
positions[2 * i + 1] = yScale(Y[i]);
|
||||
}
|
||||
return positions;
|
||||
});
|
||||
|
||||
computePointColors = memoize(rgb => {
|
||||
/*
|
||||
compute webgl colors for each point
|
||||
*/
|
||||
const colors = new Float32Array(3 * rgb.length);
|
||||
for (let i = 0, len = rgb.length; i < len; i += 1) {
|
||||
colors.set(rgb[i], 3 * i);
|
||||
}
|
||||
return colors;
|
||||
});
|
||||
|
||||
computeSelectedFlags = memoize(
|
||||
(crossfilter, flagSelected, flagUnselected) => {
|
||||
const x = crossfilter.fillByIsSelected(
|
||||
new Float32Array(crossfilter.size()),
|
||||
flagSelected,
|
||||
flagUnselected
|
||||
);
|
||||
return x;
|
||||
}
|
||||
);
|
||||
|
||||
computePointFlags = memoize(
|
||||
(world, crossfilter, colorAccessor, centroidLabel) => {
|
||||
const flagSelected = 1;
|
||||
const flagNaN = 2;
|
||||
const flagHighlight = 4;
|
||||
|
||||
const flags = this.computeSelectedFlags(
|
||||
crossfilter,
|
||||
flagSelected,
|
||||
0
|
||||
).slice();
|
||||
|
||||
const { metadataField, categoryField } = centroidLabel;
|
||||
const highlightData = metadataField
|
||||
? world.obsAnnotations.col(metadataField)?.asArray()
|
||||
: null;
|
||||
const colorByColumn = colorAccessor
|
||||
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
|
||||
world.varData.col(colorAccessor)?.asArray()
|
||||
: null;
|
||||
const colorByData =
|
||||
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
|
||||
|
||||
if (colorByData || highlightData) {
|
||||
for (let i = 0, len = flags.length; i < len; i += 1) {
|
||||
if (highlightData) {
|
||||
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
|
||||
}
|
||||
if (colorByData) {
|
||||
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.count = 0;
|
||||
this.axes = false;
|
||||
this.renderCache = {
|
||||
positions: null,
|
||||
colors: null,
|
||||
flags: null,
|
||||
xScale: null,
|
||||
yScale: null
|
||||
};
|
||||
this.state = {
|
||||
svg: null,
|
||||
minimized: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { svg } = setupScatterplot(width, height, margin);
|
||||
let scales;
|
||||
const { expressionX, expressionY } = this.props;
|
||||
|
||||
if (svg && expressionX && expressionY) {
|
||||
scales = Scatterplot.setupScales(expressionX, expressionY);
|
||||
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
|
||||
this.renderCache = { ...this.renderCache, ...scales };
|
||||
}
|
||||
|
||||
const regl = _regl(this.reglCanvas);
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// Create render transform
|
||||
const projectionTF = createProjectionTF(
|
||||
this.reglCanvas.width,
|
||||
this.reglCanvas.height
|
||||
);
|
||||
|
||||
// preallocate buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const flagBuffer = regl.buffer();
|
||||
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
flagBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
);
|
||||
|
||||
this.setState({
|
||||
regl,
|
||||
flagBuffer,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
svg,
|
||||
drawPoints,
|
||||
projectionTF
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
world,
|
||||
crossfilter,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
expressionX,
|
||||
expressionY,
|
||||
colorRGB,
|
||||
colorAccessor,
|
||||
centroidLabel
|
||||
} = this.props;
|
||||
const {
|
||||
regl,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
flagBuffer,
|
||||
svg,
|
||||
drawPoints,
|
||||
projectionTF
|
||||
} = this.state;
|
||||
|
||||
if (
|
||||
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor ||
|
||||
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor ||
|
||||
world !== prevProps.world // shape or clip of world changed
|
||||
) {
|
||||
const scales = Scatterplot.setupScales(expressionX, expressionY);
|
||||
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
|
||||
this.renderCache = { ...this.renderCache, ...scales };
|
||||
}
|
||||
|
||||
if (world && regl) {
|
||||
const { renderCache } = this;
|
||||
const { xScale, yScale } = this.renderCache;
|
||||
let needsRepaint = false;
|
||||
|
||||
const newPositions = this.computePointPositions(
|
||||
expressionX,
|
||||
expressionY,
|
||||
xScale,
|
||||
yScale
|
||||
);
|
||||
if (renderCache.positions !== newPositions) {
|
||||
renderCache.positions = newPositions;
|
||||
pointBuffer({ data: renderCache.positions, dimension: 2 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
/* colors for each point */
|
||||
const newColors = this.computePointColors(colorRGB);
|
||||
if (renderCache.colors !== newColors) {
|
||||
renderCache.colors = newColors;
|
||||
colorBuffer({ data: renderCache.colors, dimension: 3 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
const newFlags = this.computePointFlags(
|
||||
world,
|
||||
crossfilter,
|
||||
colorAccessor,
|
||||
centroidLabel
|
||||
);
|
||||
if (renderCache.flags !== newFlags) {
|
||||
renderCache.flags = newFlags;
|
||||
flagBuffer({ data: renderCache.flags, dimension: 1 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
this.count = expressionX.length;
|
||||
|
||||
if (needsRepaint) {
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
flagBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static setupScales(expressionX, expressionY) {
|
||||
const xScale = d3
|
||||
.scaleLinear()
|
||||
.domain(finiteExtent(expressionX))
|
||||
.range([0, width]);
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain(finiteExtent(expressionY))
|
||||
.range([height, 0]);
|
||||
|
||||
return {
|
||||
xScale,
|
||||
yScale
|
||||
};
|
||||
}
|
||||
|
||||
drawAxesSVG(xScale, yScale, svg) {
|
||||
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
// the axes are much cleaner and easier now. No need to rotate and orient
|
||||
// the axis, just call axisBottom, axisLeft etc.
|
||||
const xAxis = d3
|
||||
.axisBottom()
|
||||
.ticks(7)
|
||||
.scale(xScale);
|
||||
|
||||
const yAxis = d3
|
||||
.axisLeft()
|
||||
.ticks(7)
|
||||
.scale(yScale);
|
||||
|
||||
// adding axes is also simpler now, just translate x-axis to (0,height)
|
||||
// and it's alread defined to be a bottom axis.
|
||||
svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(0,${height})`)
|
||||
.attr("class", "x axis")
|
||||
.call(xAxis);
|
||||
|
||||
// y-axis is translated to (0,0)
|
||||
svg
|
||||
.append("g")
|
||||
.attr("transform", "translate(0,0)")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis);
|
||||
|
||||
// adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10).
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", 10)
|
||||
.attr("y", 10)
|
||||
.attr("class", "label")
|
||||
.style("font-style", "italic")
|
||||
.text(scatterplotYYaccessor);
|
||||
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width)
|
||||
.attr("y", height - 10)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("class", "label")
|
||||
.style("font-style", "italic")
|
||||
.text(scatterplotXXaccessor);
|
||||
}
|
||||
|
||||
renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
flagBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
) {
|
||||
if (!this.reglCanvas) return;
|
||||
const { universe, responsive } = this.props;
|
||||
// The viewport dimension is used to scale points, so we want to pass
|
||||
// the dimension of the MAIN viewport, not the scatterplot viewport.
|
||||
// Slightly hacky, but we want all points to scale uniformly. Perhaps
|
||||
// this should move to the redux state and be shared?
|
||||
const { width: cvWidth, height: cvHeight } = responsive;
|
||||
regl.poll();
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1]
|
||||
});
|
||||
drawPoints({
|
||||
flag: flagBuffer,
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
projection: projectionTF,
|
||||
count: this.count,
|
||||
nPoints: universe.nObs,
|
||||
minViewportDimension: Math.min(
|
||||
cvWidth - globals.leftSidebarWidth || width,
|
||||
cvHeight || height
|
||||
)
|
||||
});
|
||||
regl._gl.flush();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dispatch } = this.props;
|
||||
const { minimized } = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: minimized ? -height + -margin.top - 2 : 0,
|
||||
borderRadius: "3px 3px 0px 0px",
|
||||
left: globals.leftSidebarWidth + globals.scatterplotMarginLeft,
|
||||
padding: "0px 20px 20px 0px",
|
||||
backgroundColor: "white",
|
||||
/* x y blur spread color */
|
||||
boxShadow: "0px 0px 6px 2px rgba(153,153,153,0.4)"
|
||||
}}
|
||||
id="scatterplot_wrapper"
|
||||
>
|
||||
<ButtonGroup
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 5,
|
||||
top: 5
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
minimal
|
||||
onClick={() => {
|
||||
this.setState({ minimized: !minimized });
|
||||
}}
|
||||
>
|
||||
{minimized ? "show scatterplot" : "hide"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
minimal
|
||||
data-testid="clear-scatterplot"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "clear scatterplot"
|
||||
})
|
||||
}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<div
|
||||
className={styles.scatterplot}
|
||||
id="scatterplot"
|
||||
style={{
|
||||
width: `${width + margin.left + margin.right}px`,
|
||||
height: `${height + margin.top + margin.bottom}px`
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
width={width}
|
||||
height={height}
|
||||
data-testid="scatterplot"
|
||||
style={{
|
||||
marginLeft: margin.left,
|
||||
marginTop: margin.top
|
||||
}}
|
||||
ref={canvas => {
|
||||
this.reglCanvas = canvas;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Scatterplot;
|
||||
+7
-4
@@ -5,15 +5,18 @@
|
||||
******************************************
|
||||
******************************************/
|
||||
|
||||
const setupScatterplot = (width, height, margin) => {
|
||||
var container = d3.select("#scatterplot");
|
||||
import * as d3 from "d3";
|
||||
|
||||
var svg = container
|
||||
const setupScatterplot = (width, height, margin) => {
|
||||
const container = d3.select("#scatterplot");
|
||||
|
||||
const svg = container
|
||||
.append("svg")
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom)
|
||||
.attr("data-testid", "scatterplot-svg")
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
return {
|
||||
svg
|
||||
@@ -1,10 +1,5 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
|
||||
const paddingRight = 120;
|
||||
const continuousChartWidth = 340;
|
||||
|
||||
export const margin = { top: 66, right: 110, bottom: 20, left: 60 };
|
||||
export const margin = { top: 40, right: 5, bottom: 20, left: 60 };
|
||||
export const width = 340;
|
||||
export const height = 340 - margin.top - margin.bottom;
|
||||
export const innerHeight = height - 2;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Colors } from "@blueprintjs/core";
|
||||
|
||||
/* if a categorical metadata field has more options than this, truncate */
|
||||
export const maxCategoricalOptionsToDisplay = 100;
|
||||
|
||||
/* default "unassigned" value for user-created categorical metadata */
|
||||
export const unassignedCategoryLabel = "unassigned";
|
||||
|
||||
/*
|
||||
these are default values for configuration the CLI may supply.
|
||||
See the REST API and CLI specs for more info.
|
||||
*/
|
||||
export const configDefaults = {
|
||||
features: {},
|
||||
displayNames: {},
|
||||
parameters: {
|
||||
"max-category-items": 1000,
|
||||
"disable-diffexp": false,
|
||||
"diffexp-may-be-slow": false
|
||||
},
|
||||
links: {}
|
||||
};
|
||||
|
||||
/* colors */
|
||||
export const blue = Colors.BLUE3;
|
||||
export const linkBlue = Colors.BLUE5;
|
||||
export const lightestGrey = "rgb(249,249,249)";
|
||||
export const lighterGrey = "rgb(245,245,245)";
|
||||
export const lightGrey = Colors.LIGHT_GRAY1;
|
||||
export const mediumGrey = "rgb(153,153,153)";
|
||||
export const darkGrey = "rgb(102,102,102)";
|
||||
export const darkerGrey = "rgb(51,51,51)";
|
||||
|
||||
export const brightBlue = "#4a90e2";
|
||||
export const brightGreen = "#A2D729";
|
||||
export const darkGreen = "#448C4D";
|
||||
|
||||
export const nonFiniteCellColor = lightGrey;
|
||||
export const defaultCellColor = "rgb(0,0,0,1)";
|
||||
export const logoColor = "black"; /* logo pink: "#E9429A" */
|
||||
|
||||
/* typography constants */
|
||||
|
||||
export const tiniestFontSize = 12;
|
||||
export const largestFontSize = 24;
|
||||
export const uppercaseLetterSpacing = "0.04em";
|
||||
export const bolder = 700;
|
||||
export const accentFont = "Georgia,Times,Times New Roman,serif";
|
||||
export const maxParagraphWidth = 600;
|
||||
|
||||
/* layout styles constants */
|
||||
|
||||
export const cellxgeneTitleLeftPadding = 14;
|
||||
export const cellxgeneTitleTopPadding = 7;
|
||||
|
||||
export const datasetTitleMaxCharacterCount = 25;
|
||||
|
||||
export const maxControlsWidth = 800;
|
||||
|
||||
export const graphMargin = { top: 20, right: 10, bottom: 30, left: 40 };
|
||||
export const graphWidth = 700;
|
||||
export const graphHeight = 700;
|
||||
export const scatterplotMarginLeft = 25;
|
||||
|
||||
export const leftSidebarWidth = 365;
|
||||
export const leftSidebarSectionHeading = {
|
||||
fontSize: 18,
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
letterSpacing: ".05em"
|
||||
};
|
||||
export const leftSidebarSectionPadding = 10;
|
||||
export const categoryLabelDisplayStringLongLength = 27;
|
||||
export const categoryLabelDisplayStringShortLength = 11;
|
||||
|
||||
/* various timing-related behaviors */
|
||||
export const tooltipHoverOpenDelay = 1000; /* ms delay before a tooltip displays */
|
||||
export const tooltipHoverOpenDelayQuick = 500;
|
||||
|
||||
let _API = {
|
||||
// prefix: "http://api.clustering.czi.technology/api/",
|
||||
// prefix: "http://tabulamuris.cxg.czi.technology/api/",
|
||||
// prefix: "http://api-staging.clustering.czi.technology/api/",
|
||||
prefix: "http://localhost:5005/api/",
|
||||
version: "v0.2/"
|
||||
};
|
||||
|
||||
if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.API;
|
||||
export const API = _API;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
>>>GLOBAL<<< CSS imports & definitions
|
||||
|
||||
Do NOT put anything in this file that is not global. We do not perform
|
||||
namespace mangling on these definitions. See webpack config for specifics.
|
||||
*/
|
||||
@import "~@blueprintjs/core/lib/css/blueprint.css";
|
||||
@import "~@blueprintjs/icons/lib/css/blueprint-icons.css";
|
||||
@import "~@blueprintjs/select/lib/css/blueprint-select.css";
|
||||
@@ -0,0 +1,21 @@
|
||||
// jshint esversion: 6
|
||||
/* eslint-disable no-console */
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import { FocusStyleManager } from "@blueprintjs/core";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
/* our code */
|
||||
import App from "./components/app";
|
||||
import store from "./reducers";
|
||||
|
||||
FocusStyleManager.onlyShowFocusOnTabs();
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Reducers for annotation UI-state.
|
||||
*/
|
||||
const Annotations = (
|
||||
state = {
|
||||
/*
|
||||
Annotations collection name - which will be used to save the named set of annotations
|
||||
in some persistent store (database, file system, etc).
|
||||
|
||||
The backend may expect this to be a legal file name, which is typically alpha-numeric, plus [_-,.].
|
||||
Keep it simple or the server may return an error.
|
||||
|
||||
If `dataCollectionNameIsReadOnly` is true, you may NOT change the data collection name.
|
||||
If false, you may change `dataCollectionName` and it will be used at the time the annotations are
|
||||
written to the back-end.
|
||||
|
||||
*/
|
||||
dataCollectionNameIsReadOnly: true,
|
||||
dataCollectionName: null,
|
||||
|
||||
/*
|
||||
Annotations UI component state
|
||||
*/
|
||||
isEditingCategoryName: false,
|
||||
isEditingLabelName: false,
|
||||
categoryBeingEdited: null,
|
||||
categoryAddingNewLabel: null,
|
||||
labelEditable: { category: null, label: null }
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "configuration load complete": {
|
||||
const DefaultDataCollectionName = null;
|
||||
const dataCollectionName =
|
||||
action.config.parameters?.["annotations-data-collection-name"] ?? null;
|
||||
const dataCollectionNameIsReadOnly =
|
||||
action.config.parameters?.[
|
||||
"annotations-data-collection-name-is-read-only"
|
||||
] ?? false;
|
||||
return {
|
||||
...state,
|
||||
dataCollectionNameIsReadOnly,
|
||||
dataCollectionName
|
||||
};
|
||||
}
|
||||
|
||||
case "set annotations collection name": {
|
||||
if (state.dataCollectionNameIsReadOnly) {
|
||||
throw new Error("data collection name is read only");
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
dataCollectionName: action.data
|
||||
};
|
||||
}
|
||||
|
||||
/* CATEGORY */
|
||||
case "annotation: activate add new label mode":
|
||||
return {
|
||||
...state,
|
||||
isAddingNewLabel: true,
|
||||
categoryAddingNewLabel: action.data
|
||||
};
|
||||
case "annotation: disable add new label mode":
|
||||
return {
|
||||
...state,
|
||||
isAddingNewLabel: false,
|
||||
categoryAddingNewLabel: null
|
||||
};
|
||||
case "annotation: add new label to category":
|
||||
return {
|
||||
...state,
|
||||
isAddingNewLabel: false,
|
||||
categoryAddingNewLabel: null
|
||||
};
|
||||
case "annotation: activate category edit mode":
|
||||
return {
|
||||
...state,
|
||||
isEditingCategoryName: true,
|
||||
categoryBeingEdited: action.data
|
||||
};
|
||||
case "annotation: disable category edit mode":
|
||||
return {
|
||||
...state,
|
||||
isEditingCategoryName: false,
|
||||
categoryBeingEdited: null
|
||||
};
|
||||
case "annotation: category edited":
|
||||
return {
|
||||
...state,
|
||||
isEditingCategoryName: false,
|
||||
categoryBeingEdited: null
|
||||
};
|
||||
|
||||
/* LABEL */
|
||||
case "annotation: activate edit label mode":
|
||||
return {
|
||||
...state,
|
||||
isEditingLabelName: true,
|
||||
labelEditable: {
|
||||
category: action.metadataField,
|
||||
label: action.categoryIndex
|
||||
}
|
||||
};
|
||||
case "annotation: cancel edit label mode":
|
||||
return {
|
||||
...state,
|
||||
isEditingLabelName: false,
|
||||
labelEditable: { category: null, label: null }
|
||||
};
|
||||
case "annotation: label edited":
|
||||
return {
|
||||
...state,
|
||||
isEditingLabelName: false,
|
||||
labelEditable: { category: null, label: null }
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Annotations;
|
||||
@@ -0,0 +1,53 @@
|
||||
const Autosave = (
|
||||
state = {
|
||||
saveInProgress: false,
|
||||
error: false,
|
||||
lastSavedObsAnnotations: null
|
||||
},
|
||||
action,
|
||||
nextSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
/* don't save on init */
|
||||
const { universe } = nextSharedState;
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
saveInProgress: false,
|
||||
lastSavedObsAnnotations: universe.obsAnnotations
|
||||
};
|
||||
}
|
||||
|
||||
case "writable obs annotations - save started": {
|
||||
return {
|
||||
...state,
|
||||
saveInProgress: true
|
||||
};
|
||||
}
|
||||
|
||||
case "writable obs annotations - save error": {
|
||||
const { message } = action;
|
||||
return {
|
||||
...state,
|
||||
error: message,
|
||||
saveInProgress: false
|
||||
};
|
||||
}
|
||||
|
||||
case "writable obs annotations - save complete": {
|
||||
const lastSavedObsAnnotations = action.obsAnnotations;
|
||||
return {
|
||||
...state,
|
||||
saveInProgress: false,
|
||||
error: false,
|
||||
lastSavedObsAnnotations
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
};
|
||||
|
||||
export default Autosave;
|
||||
@@ -0,0 +1,44 @@
|
||||
export default function cascadeReducers(arg) {
|
||||
/*
|
||||
Combined a set of cascading reducers into a single reducer. Cascading
|
||||
reducers are reducers which may rely on state computed by another reducer.
|
||||
Therefore, they:
|
||||
- must be composed in a particular order (currently, this is a simple
|
||||
linear list of reducers, run in list order)
|
||||
- must have access to partially updated "next state" so they can further
|
||||
derive state.
|
||||
|
||||
Parameter is one of:
|
||||
- a Map object
|
||||
- an array of tuples, [ [key1, reducer1], [key2, reducer2], ... ]
|
||||
Ie, cascadeReducers([ ["a", reduceA], ["b", reduceB] ])
|
||||
|
||||
Each reducer will be called with the sigature:
|
||||
(prevState, action, sharedNextState, sharedPrevState) => newState
|
||||
|
||||
cascadeReducers will build a composite newState object, much
|
||||
like combinedReducers. Additional semantics:
|
||||
- reducers guaranteed to be called in order
|
||||
- each reducer will receive shared objects
|
||||
*/
|
||||
const reducers = arg instanceof Map ? arg : new Map(arg);
|
||||
const reducerKeys = [...reducers.keys()];
|
||||
return (prevState, action) => {
|
||||
const nextState = {};
|
||||
let stateChange = false;
|
||||
for (let i = 0, l = reducerKeys.length; i < l; i += 1) {
|
||||
const key = reducerKeys[i];
|
||||
const reducer = reducers.get(key);
|
||||
const prevStateForKey = prevState ? prevState[key] : undefined;
|
||||
const nextStateForKey = reducer(
|
||||
prevStateForKey,
|
||||
action,
|
||||
nextState,
|
||||
prevState
|
||||
);
|
||||
nextState[key] = nextStateForKey;
|
||||
stateChange = stateChange || nextStateForKey !== prevStateForKey;
|
||||
}
|
||||
return stateChange ? nextState : prevState;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ControlsHelpers as CH } from "../util/stateManager";
|
||||
import * as globals from "../globals";
|
||||
|
||||
function maxCategoryItems(state) {
|
||||
return (
|
||||
state.config.parameters?.["max-category-items"] ??
|
||||
globals.configDefaults.parameters["max-category-items"]
|
||||
);
|
||||
}
|
||||
|
||||
const CategoricalSelection = (
|
||||
state,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)":
|
||||
case "set World to current selection":
|
||||
case "reset World to eq Universe":
|
||||
case "set clip quantiles": {
|
||||
const { world } = nextSharedState;
|
||||
const newState = CH.createCategoricalSelection(
|
||||
world,
|
||||
CH.selectableCategoryNames(world, maxCategoryItems(prevSharedState))
|
||||
);
|
||||
return newState;
|
||||
}
|
||||
|
||||
case "categorical metadata filter select": {
|
||||
/*
|
||||
Set the specific category in this field to false
|
||||
*/
|
||||
const newCategoryValueSelected = Array.from(
|
||||
state[action.metadataField].categoryValueSelected
|
||||
);
|
||||
newCategoryValueSelected[action.categoryIndex] = true;
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categoryValueSelected: newCategoryValueSelected
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter deselect": {
|
||||
/*
|
||||
Set the specific category in this field to false
|
||||
*/
|
||||
const newCategoryValueSelected = Array.from(
|
||||
state[action.metadataField].categoryValueSelected
|
||||
);
|
||||
newCategoryValueSelected[action.categoryIndex] = false;
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categoryValueSelected: newCategoryValueSelected
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter none of these": {
|
||||
/*
|
||||
set all categories in this field to false.
|
||||
*/
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: false,
|
||||
categoryValueSelected: Array.from(
|
||||
state[action.metadataField].categoryValueSelected
|
||||
).fill(false)
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter all of these": {
|
||||
/*
|
||||
set all categories in this field to true.
|
||||
*/
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: {
|
||||
...state[action.metadataField],
|
||||
categorySelected: true,
|
||||
categoryValueSelected: Array.from(
|
||||
state[action.metadataField].categoryValueSelected
|
||||
).fill(true)
|
||||
}
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "annotation: create category": {
|
||||
const { world } = nextSharedState;
|
||||
const name = action.data;
|
||||
return {
|
||||
...state,
|
||||
...CH.createCategoricalSelection(world, [name])
|
||||
};
|
||||
}
|
||||
|
||||
case "annotation: category edited": {
|
||||
const name = action.metadataField;
|
||||
const newName = action.newCategoryText;
|
||||
const { [name]: catSeln, ...newState } = state;
|
||||
newState[newName] = catSeln;
|
||||
return newState;
|
||||
}
|
||||
|
||||
case "annotation: delete category": {
|
||||
const name = action.metadataField;
|
||||
const { [name]: _, ...newState } = state;
|
||||
return newState;
|
||||
}
|
||||
|
||||
case "annotation: label current cell selection":
|
||||
case "annotation: add new label to category":
|
||||
case "annotation: label edited":
|
||||
case "annotation: delete label": {
|
||||
/* need to rebuild the state for this annotation */
|
||||
const { world } = nextSharedState;
|
||||
const name = action.metadataField;
|
||||
const { [name]: _, ...partialState } = state;
|
||||
return {
|
||||
...partialState,
|
||||
...CH.createCategoricalSelection(world, [name])
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default CategoricalSelection;
|
||||
@@ -0,0 +1,37 @@
|
||||
const initialState = {
|
||||
metadataField: "",
|
||||
categoryIndex: -1,
|
||||
categoryField: "",
|
||||
centroidXY: [-1, -1]
|
||||
};
|
||||
|
||||
const CentroidLabel = (state = initialState, action, sharedNextState) => {
|
||||
const { categoricalSelection } = sharedNextState;
|
||||
const { metadataField, categoryIndex } = action;
|
||||
const categoryField =
|
||||
categoricalSelection?.[metadataField]?.categoryValues[categoryIndex];
|
||||
switch (action.type) {
|
||||
case "category value mouse hover start":
|
||||
return {
|
||||
...state,
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
categoryField,
|
||||
centroidXY: null
|
||||
};
|
||||
|
||||
case "category value mouse hover end":
|
||||
if (
|
||||
metadataField === state.metadataField &&
|
||||
categoryIndex === state.categoryIndex
|
||||
) {
|
||||
return initialState;
|
||||
}
|
||||
return state;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default CentroidLabel;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ColorHelpers } from "../util/stateManager";
|
||||
|
||||
const ColorsReducer = (
|
||||
state = {
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
rgb: null,
|
||||
scale: null
|
||||
},
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)":
|
||||
case "reset World to eq Universe": {
|
||||
const { world } = nextSharedState;
|
||||
const colorMode = null;
|
||||
const colorAccessor = null;
|
||||
const { rgb, scale } = ColorHelpers.createColors(world, colorMode);
|
||||
return {
|
||||
...state,
|
||||
colorAccessor,
|
||||
colorMode,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "set clip quantiles":
|
||||
case "set World to current selection": {
|
||||
const { world: prevWorld, controls: prevControls } = prevSharedState;
|
||||
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
|
||||
prevControls,
|
||||
state,
|
||||
prevWorld
|
||||
);
|
||||
if (resetColorState) {
|
||||
return resetColorState;
|
||||
}
|
||||
|
||||
const { colorMode, colorAccessor } = state;
|
||||
const { world } = nextSharedState;
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "annotation: delete category": {
|
||||
const { colorAccessor } = state;
|
||||
if (action.metadataField !== colorAccessor) {
|
||||
return state;
|
||||
}
|
||||
/* else reset */
|
||||
return {
|
||||
...state,
|
||||
...ColorHelpers.resetColors(prevSharedState.world)
|
||||
};
|
||||
}
|
||||
|
||||
case "reset colorscale": {
|
||||
return {
|
||||
...state,
|
||||
...ColorHelpers.resetColors(prevSharedState.world)
|
||||
};
|
||||
}
|
||||
|
||||
case "color by categorical metadata":
|
||||
case "color by continuous metadata": {
|
||||
const { world } = prevSharedState;
|
||||
|
||||
/* toggle between this mode and reset */
|
||||
const resetCurrent =
|
||||
action.type === state.colorMode &&
|
||||
action.colorAccessor === state.colorAccessor;
|
||||
const colorMode = !resetCurrent ? action.type : null;
|
||||
const colorAccessor = !resetCurrent ? action.colorAccessor : null;
|
||||
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "color by expression": {
|
||||
const { world } = prevSharedState;
|
||||
|
||||
/* toggle between this mode and reset */
|
||||
const resetCurrent =
|
||||
action.type === state.colorMode && action.gene === state.colorAccessor;
|
||||
const colorMode = !resetCurrent ? action.type : null;
|
||||
const colorAccessor = !resetCurrent ? action.gene : null;
|
||||
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
rgb,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
case "annotation: add new label to category":
|
||||
case "annotation: label current cell selection":
|
||||
case "annotation: delete label": {
|
||||
const { world } = nextSharedState;
|
||||
const { colorMode, colorAccessor } = state;
|
||||
const { metadataField } = action;
|
||||
if (
|
||||
colorMode !== "color by categorical metadata" ||
|
||||
colorAccessor !== metadataField
|
||||
)
|
||||
return state;
|
||||
|
||||
/* else, we need to rebuild colors as labels have changed! */
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
);
|
||||
return { ...state, rgb, scale };
|
||||
}
|
||||
|
||||
case "clear differential expression": {
|
||||
const { world: prevWorld, controls: prevControls } = prevSharedState;
|
||||
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
|
||||
prevControls,
|
||||
state,
|
||||
prevWorld
|
||||
);
|
||||
if (resetColorState) {
|
||||
return resetColorState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ColorsReducer;
|
||||
@@ -0,0 +1,34 @@
|
||||
// jshint esversion: 6
|
||||
const Config = (
|
||||
state = {
|
||||
displayNames: null,
|
||||
features: null,
|
||||
parameters: null
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load start":
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
error: null
|
||||
};
|
||||
case "configuration load complete":
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: null,
|
||||
...action.config
|
||||
};
|
||||
case "initial data load error":
|
||||
return {
|
||||
...state,
|
||||
error: action.error
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Config;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { makeContinuousDimensionName } from "../util/nameCreators";
|
||||
|
||||
const ContinuousSelection = (state = {}, action) => {
|
||||
switch (action.type) {
|
||||
case "reset World to eq Universe":
|
||||
case "set clip quantiles": {
|
||||
return {};
|
||||
}
|
||||
case "continuous metadata histogram start":
|
||||
case "continuous metadata histogram brush":
|
||||
case "continuous metadata histogram end": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
action.selection
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
[name]: action.range
|
||||
};
|
||||
}
|
||||
case "continuous metadata histogram cancel": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
action.selection
|
||||
);
|
||||
const { [name]: deletedField, ...newState } = state;
|
||||
return newState;
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ContinuousSelection;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user