gene set summary progress (#2127)

* revert removal of cache control headers

* checkpoint work on revising summary route

* add summary query support to annoMatrix

* summarize route cleanup

* add mising file

* clean up summarize route

* add summary histogram

* update deps

* lint

* more lint

* lint

* manage crossfiler during gene set state changes

* remove obsolete debugging code

* correctly perform async watch in histogram

* better error handling
This commit is contained in:
Bruce Martin
2021-03-30 14:43:53 -07:00
committed by GitHub
parent bfb9e1edcc
commit ae30b66123
47 changed files with 28628 additions and 9109 deletions
+9 -4
View File
@@ -206,11 +206,16 @@ class GenesetsAPI(Resource):
return common_rest.genesets_put(request, data_adaptor) return common_rest.genesets_put(request, data_adaptor)
class GenesetSummaryAPI(Resource): class SummarizeVarAPI(Resource):
@rest_get_data_adaptor
@cache_control(public=True, max_age=ONE_WEEK)
def get(self, data_adaptor):
return common_rest.summarize_var_get(request, data_adaptor)
@rest_get_data_adaptor @rest_get_data_adaptor
@cache_control(no_store=True) @cache_control(no_store=True)
def get(self, data_adaptor): def post(self, data_adaptor):
return common_rest.geneset_summary_get(request, data_adaptor) return common_rest.summarize_var_post(request, data_adaptor)
def get_api_base_resources(bp_base): def get_api_base_resources(bp_base):
@@ -239,7 +244,7 @@ def get_api_dataroot_resources(bp_dataroot):
add_resource(AnnotationsVarAPI, "/annotations/var") add_resource(AnnotationsVarAPI, "/annotations/var")
add_resource(DataVarAPI, "/data/var") add_resource(DataVarAPI, "/data/var")
add_resource(GenesetsAPI, "/genesets") add_resource(GenesetsAPI, "/genesets")
add_resource(GenesetSummaryAPI, "/geneset_summary") add_resource(SummarizeVarAPI, "/summarize/var")
# Display routes # Display routes
add_resource(ColorsAPI, "/colors") add_resource(ColorsAPI, "/colors")
# Computation routes # Computation routes
+27 -15
View File
@@ -3,6 +3,7 @@ import logging
import sys import sys
from http import HTTPStatus from http import HTTPStatus
import zlib import zlib
import hashlib
from flask import make_response, jsonify, current_app, abort from flask import make_response, jsonify, current_app, abort
from werkzeug.urls import url_unquote from werkzeug.urls import url_unquote
@@ -383,31 +384,42 @@ def genesets_put(request, data_adaptor):
return abort(HTTPStatus.NOT_FOUND, description=str(e)) return abort(HTTPStatus.NOT_FOUND, description=str(e))
def geneset_summary_get(request, data_adaptor): def summarize_var_helper(request, data_adaptor, key, raw_query):
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"]) preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
if preferred_mimetype != "application/octet-stream": if preferred_mimetype != "application/octet-stream":
return abort(HTTPStatus.NOT_ACCEPTABLE) return abort(HTTPStatus.NOT_ACCEPTABLE)
geneset_name = request.args.get("geneset_name", default=None) summary_method = request.values.get("method", default="mean")
summary_method = request.args.get("method", default="mean") query_hash = hashlib.sha1(raw_query).hexdigest() # cache helper
request_tid = request.args.get("tid", default=None) if key and query_hash != key:
return abort(HTTPStatus.BAD_REQUEST, description="query key did not match")
args_filter_only = request.values.copy()
args_filter_only.poplist("method")
args_filter_only.poplist("key")
try: try:
annotations = data_adaptor.dataset_config.user_annotations filter = _query_parameter_to_filter(args_filter_only)
(genesets, tid) = annotations.read_gene_sets(data_adaptor)
if request_tid is not None and int(request_tid) != tid:
return abort(HTTPStatus.NOT_FOUND, "Obsolete TID")
if geneset_name is None or geneset_name not in genesets:
return abort(HTTPStatus.BAD_REQUEST, "Gene set name not found.")
genes = [g["gene_symbol"] for g in genesets.get(geneset_name)["genes"]]
return make_response( return make_response(
data_adaptor.get_gene_set_summary(geneset_name, genes, summary_method), data_adaptor.summarize_var(summary_method, filter, query_hash),
HTTPStatus.OK, HTTPStatus.OK,
{"Content-Type": "application/octet-stream"}, {"Content-Type": "application/octet-stream"},
) )
except (ValueError) as e: except (ValueError) as e:
return abort(HTTPStatus.NOT_FOUND, description=str(e)) return abort(HTTPStatus.NOT_FOUND, description=str(e))
except (UnsupportedSummaryMethod) as e: except (UnsupportedSummaryMethod, FilterError) as e:
return abort(HTTPStatus.BAD_REQUEST, description=str(e)) return abort(HTTPStatus.BAD_REQUEST, description=str(e))
def summarize_var_get(request, data_adaptor):
return summarize_var_helper(request, data_adaptor, None, request.query_string)
def summarize_var_post(request, data_adaptor):
if not request.content_type or "application/x-www-form-urlencoded" not in request.content_type:
return abort(HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
if request.content_length > 1_000_000: # just a sanity check to avoid memory exhaustion
return abort(HTTPStatus.BAD_REQUEST)
key = request.args.get("key", default=None)
return summarize_var_helper(request, data_adaptor, key, request.get_data())
+9 -36
View File
@@ -5,7 +5,6 @@ import anndata
import numpy as np import numpy as np
from packaging import version from packaging import version
from pandas.core.dtypes.dtypes import CategoricalDtype from pandas.core.dtypes.dtypes import CategoricalDtype
import pandas as pd
from scipy import sparse from scipy import sparse
from server_timing import Timing as ServerTiming from server_timing import Timing as ServerTiming
@@ -13,7 +12,7 @@ import backend.server.compute.diffexp_generic as diffexp_generic
from backend.common.colors import convert_anndata_category_colors_to_cxg_category_colors from backend.common.colors import convert_anndata_category_colors_to_cxg_category_colors
from backend.common.constants import Axis, MAX_LAYOUTS from backend.common.constants import Axis, MAX_LAYOUTS
from backend.server.common.corpora import corpora_get_props_from_anndata from backend.server.common.corpora import corpora_get_props_from_anndata
from backend.common.errors import PrepareError, DatasetAccessError, FilterError, UnsupportedSummaryMethod from backend.common.errors import PrepareError, DatasetAccessError, FilterError
from backend.common.utils.type_conversion_utils import get_schema_type_hint_of_array from backend.common.utils.type_conversion_utils import get_schema_type_hint_of_array
from backend.server.compute.scanpy import scanpy_umap from backend.server.compute.scanpy import scanpy_umap
from backend.server.data_common.data_adaptor import DataAdaptor from backend.server.data_common.data_adaptor import DataAdaptor
@@ -69,11 +68,11 @@ class AnndataAdaptor(DataAdaptor):
@staticmethod @staticmethod
def _create_unique_column_name(df, col_name_prefix): def _create_unique_column_name(df, col_name_prefix):
""" given the columns of a dataframe, and a name prefix, return a column name which """given the columns of a dataframe, and a name prefix, return a column name which
does not exist in the dataframe, AND which is prefixed by `prefix` does not exist in the dataframe, AND which is prefixed by `prefix`
The approach is to append a numeric suffix, starting at zero and increasing by The approach is to append a numeric suffix, starting at zero and increasing by
one, until an unused name is found (eg, prefix_0, prefix_1, ...). one, until an unused name is found (eg, prefix_0, prefix_1, ...).
""" """
suffix = 0 suffix = 0
while f"{col_name_prefix}{suffix}" in df: while f"{col_name_prefix}{suffix}" in df:
@@ -200,10 +199,10 @@ class AnndataAdaptor(DataAdaptor):
self.parameters.update({"diffexp_may_be_slow": True}) self.parameters.update({"diffexp_may_be_slow": True})
def _is_valid_layout(self, arr): def _is_valid_layout(self, arr):
""" return True if this layout data is a valid array for front-end presentation: """return True if this layout data is a valid array for front-end presentation:
* ndarray, dtype float/int/uint * ndarray, dtype float/int/uint
* with shape (n_obs, >= 2) * with shape (n_obs, >= 2)
* with all values finite or NaN (no +Inf or -Inf) * with all values finite or NaN (no +Inf or -Inf)
""" """
is_valid = type(arr) == np.ndarray and arr.dtype.kind in "fiu" is_valid = type(arr) == np.ndarray and arr.dtype.kind in "fiu"
is_valid = is_valid and arr.shape[0] == self.data.n_obs and arr.shape[1] >= 2 is_valid = is_valid and arr.shape[0] == self.data.n_obs and arr.shape[1] >= 2
@@ -368,29 +367,3 @@ class AnndataAdaptor(DataAdaptor):
def get_var_keys(self): def get_var_keys(self):
# return list of keys # return list of keys
return self.data.var.keys().to_list() return self.data.var.keys().to_list()
def get_gene_set_summary(self, geneset_name, genes, method):
if method != "mean":
raise UnsupportedSummaryMethod("Unknown gene set summary method.")
var_index = self.parameters.get("var_names")
obs_selector, var_selector = self._filter_to_mask(
{
"var": {
"annotation_value": [
{
"name": var_index,
"values": genes,
}
]
}
}
)
X = self.get_X_array(obs_selector, var_selector)
if sparse.issparse(X):
mean = X.mean(axis=1)
else:
mean = X.mean(axis=1, keepdims=True)
col_idx = pd.Index([geneset_name])
return encode_matrix_fbs(mean, col_idx=col_idx, row_idx=None)
+24 -5
View File
@@ -1,14 +1,14 @@
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from os.path import basename, splitext from os.path import basename, splitext
import re import re
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from scipy import sparse
from server_timing import Timing as ServerTiming from server_timing import Timing as ServerTiming
from backend.server.common.config.app_config import AppConfig from backend.server.common.config.app_config import AppConfig
from backend.common.constants import Axis from backend.common.constants import Axis
from backend.common.errors import FilterError, JSONEncodingValueError, ExceedsLimitError from backend.common.errors import FilterError, JSONEncodingValueError, ExceedsLimitError, UnsupportedSummaryMethod
from backend.common.utils.utils import jsonify_numpy from backend.common.utils.utils import jsonify_numpy
from backend.common.fbs.matrix import encode_matrix_fbs from backend.common.fbs.matrix import encode_matrix_fbs
@@ -482,6 +482,25 @@ class DataAdaptor(metaclass=ABCMeta):
lastmod = None lastmod = None
return lastmod return lastmod
@abstractmethod def summarize_var(self, method, filter, query_hash):
def get_gene_set_summary(self, geneset_name, genes, method): if method != "mean":
pass raise UnsupportedSummaryMethod("Unknown gene set summary method.")
obs_selector, var_selector = self._filter_to_mask(filter)
if obs_selector is not None:
raise FilterError("filtering on obs unsupported")
# if no filter, just return zeros. We don't have a use case
# for summarizing the entire X without a filter, and it would
# potentially be quite compute / memory intensive.
if var_selector is None or np.count_nonzero(var_selector) == 0:
mean = np.zeros((self.get_shape()[0], 1), dtype=np.float32)
else:
X = self.get_X_array(obs_selector, var_selector)
if sparse.issparse(X):
mean = X.mean(axis=1)
else:
mean = X.mean(axis=1, keepdims=True)
col_idx = pd.Index([query_hash])
return encode_matrix_fbs(mean, col_idx=col_idx, row_idx=None)
@@ -5,6 +5,7 @@ import zlib
from http import HTTPStatus from http import HTTPStatus
import tempfile import tempfile
from os import path from os import path
import hashlib
import pandas as pd import pandas as pd
import requests import requests
@@ -430,6 +431,75 @@ class EndPointsAnndata(unittest.TestCase, EndPoints):
result_data = result.json() result_data = result.json()
self.assertEqual(len(result_data), 10) self.assertEqual(len(result_data), 10)
def test_get_summaryvar(self):
index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
endpoint = "summarize/var"
# single column
filter = f"var:{index_col_name}=F5"
query = f"method=mean&{filter}"
query_hash = hashlib.sha1(query.encode()).hexdigest()
url = f"{self.URL_BASE}{endpoint}?{query}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1)
self.assertEqual(df["col_idx"], [query_hash])
self.assertAlmostEqual(df["columns"][0][0], -0.110451095)
# multi-column
col_names = ["F5", "BEB3", "SIK1"]
filter = "&".join([f"var:{index_col_name}={name}" for name in col_names])
query = f"method=mean&{filter}"
query_hash = hashlib.sha1(query.encode()).hexdigest()
url = f"{self.URL_BASE}{endpoint}?{query}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1)
self.assertEqual(df["col_idx"], [query_hash])
self.assertAlmostEqual(df["columns"][0][0], -0.16628358)
def test_post_summaryvar(self):
index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
endpoint = "summarize/var"
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/octet-stream"}
# single column
filter = f"var:{index_col_name}=F5"
query = f"method=mean&{filter}"
query_hash = hashlib.sha1(query.encode()).hexdigest()
url = f"{self.URL_BASE}{endpoint}?key={query_hash}"
result = self.session.post(url, headers=headers, data=query)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1)
self.assertEqual(df["col_idx"], [query_hash])
self.assertAlmostEqual(df["columns"][0][0], -0.110451095)
# multi-column
col_names = ["F5", "BEB3", "SIK1"]
filter = "&".join([f"var:{index_col_name}={name}" for name in col_names])
query = f"method=mean&{filter}"
query_hash = hashlib.sha1(query.encode()).hexdigest()
url = f"{self.URL_BASE}{endpoint}?key={query_hash}"
result = self.session.post(url, headers=headers, data=query)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1)
self.assertEqual(df["col_idx"], [query_hash])
self.assertAlmostEqual(df["columns"][0][0], -0.16628358)
class EndPointsAnndataAnnotations(unittest.TestCase, EndPointsAnnotations): class EndPointsAnndataAnnotations(unittest.TestCase, EndPointsAnnotations):
"""Test Case for endpoints""" """Test Case for endpoints"""
@@ -738,86 +808,25 @@ summary test,,PIGU,\r
original_data, original_data,
) )
def test_get_geneset_summary(self):
endpoint = "geneset_summary?geneset_name=summary%20test&method=mean"
url = f"{self.URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1)
self.assertEqual(df["col_idx"], ["summary test"])
self.assertAlmostEqual(df["columns"][0][0], -0.19863907)
def test_get_geneset_summary_default_method(self):
endpoint = "geneset_summary?geneset_name=summary%20test"
url = f"{self.URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1)
self.assertEqual(df["col_idx"], ["summary test"])
self.assertAlmostEqual(df["columns"][0][0], -0.19863907)
def test_get_geneset_summary_check_tid(self):
# get the TID
result = self.session.get(f"{self.URL_BASE}genesets", headers={"Accept": "application/json"})
self.assertEqual(result.status_code, HTTPStatus.OK)
tid = result.json()["tid"]
# current tid
endpoint = f"geneset_summary?geneset_name=summary%20test&tid={tid}"
result = self.session.get(f"{self.URL_BASE}{endpoint}", headers={"Accept": "application/octet-stream"})
self.assertEqual(result.status_code, HTTPStatus.OK)
# future tid
endpoint = f"geneset_summary?geneset_name=summary%20test&tid={tid+1}"
result = self.session.get(f"{self.URL_BASE}{endpoint}", headers={"Accept": "application/octet-stream"})
self.assertEqual(result.status_code, HTTPStatus.NOT_FOUND)
# past tid
endpoint = f"geneset_summary?geneset_name=summary%20test&tid={tid-1}"
result = self.session.get(f"{self.URL_BASE}{endpoint}", headers={"Accept": "application/octet-stream"})
self.assertEqual(result.status_code, HTTPStatus.NOT_FOUND)
# No tid - ie, skip check
endpoint = "geneset_summary?geneset_name=summary%20test"
result = self.session.get(f"{self.URL_BASE}{endpoint}", headers={"Accept": "application/octet-stream"})
self.assertEqual(result.status_code, HTTPStatus.OK)
def test_get_geneset_summary_edge_cases(self): def test_get_geneset_summary_edge_cases(self):
# attempt to summarize _all_ genesets, including edge cases with zero or one gene # attempt to summarize _all_ genesets, including edge cases with zero or one gene
result = self.session.get(f"{self.URL_BASE}genesets", headers={"Accept": "application/json"}) result = self.session.get(f"{self.URL_BASE}genesets", headers={"Accept": "application/json"})
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.OK)
geneset_names = [gs["geneset_name"] for gs in result.json()["genesets"]] genesets = result.json()["genesets"]
for gs in geneset_names: endpoint = "summarize/var"
endpoint = f"geneset_summary?geneset_name={gs}" index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
result = self.session.get(f"{self.URL_BASE}{endpoint}", headers={"Accept": "application/octet-stream"}) for gs in genesets:
genes = [g["gene_symbol"] for g in gs["genes"]]
filter = "&".join([f"var:{index_col_name}={gene}" for gene in genes])
query = f"method=mean&{filter}"
query_hash = hashlib.sha1(query.encode()).hexdigest()
url = f"{self.URL_BASE}{endpoint}?{query}"
result = self.session.get(url, headers={"Accept": "application/octet-stream"})
self.assertEqual(result.status_code, HTTPStatus.OK) self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream") self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content) df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638) self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1) self.assertEqual(df["n_cols"], 1)
self.assertEqual(df["col_idx"], [gs]) self.assertEqual(df["col_idx"], [query_hash])
def test_get_geneset_error_handling(self):
# no geneset
endpoint = "geneset_summary"
result = self.session.get(f"{self.URL_BASE}{endpoint}", headers={"Accept": "application/octet-stream"})
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
# unknown geneset
endpoint = "geneset_summary?geneset_name=NO_SUCH_GENE_SET"
result = self.session.get(f"{self.URL_BASE}{endpoint}", headers={"Accept": "application/octet-stream"})
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
# unknown method
endpoint = "geneset_summary?geneset_name=summary%20test&method=NO_SUCH_METHOD"
result = self.session.get(f"{self.URL_BASE}{endpoint}", headers={"Accept": "application/octet-stream"})
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
@@ -85,9 +85,11 @@ describe("AnnoMatrix", () => {
fetch.once(serverMocks.responder); fetch.once(serverMocks.responder);
await expect( await expect(
annoMatrix.fetch("X", { annoMatrix.fetch("X", {
field: "var", where: {
column: annoMatrix.schema.annotations.var.index, field: "var",
value: "TYMP", column: annoMatrix.schema.annotations.var.index,
value: "TYMP",
},
}) })
).resolves.toBeInstanceOf(Dataframe); ).resolves.toBeInstanceOf(Dataframe);
@@ -103,14 +105,18 @@ describe("AnnoMatrix", () => {
await expect( await expect(
annoMatrix.fetch("X", [ annoMatrix.fetch("X", [
{ {
field: "var", where: {
column: varIndex, field: "var",
value: "SUMO3", column: varIndex,
value: "SUMO3",
},
}, },
{ {
field: "var", where: {
column: varIndex, field: "var",
value: "TYMP", column: varIndex,
value: "TYMP",
},
}, },
]) ])
).resolves.toBeInstanceOf(Dataframe); ).resolves.toBeInstanceOf(Dataframe);
@@ -170,9 +170,11 @@ describe("AnnoMatrixCrossfilter", () => {
const xfltr = await crossfilter.select( const xfltr = await crossfilter.select(
"X", "X",
{ {
field: "var", where: {
column: varIndex, field: "var",
value: "TYMP", column: varIndex,
value: "TYMP",
},
}, },
{ {
mode: "range", mode: "range",
@@ -186,9 +188,11 @@ describe("AnnoMatrixCrossfilter", () => {
expect(xfltr.countSelected()).toEqual(501); expect(xfltr.countSelected()).toEqual(501);
const df = await annoMatrix.fetch("X", { const df = await annoMatrix.fetch("X", {
field: "var", where: {
column: varIndex, field: "var",
value: "TYMP", column: varIndex,
value: "TYMP",
},
}); });
const values = df.icol(0).asArray(); const values = df.icol(0).asArray();
const selected = xfltr.allSelectedMask(); const selected = xfltr.allSelectedMask();
@@ -1,3 +1,4 @@
import sha1 from "sha1";
import { import {
_whereCacheGet, _whereCacheGet,
_whereCacheCreate, _whereCacheCreate,
@@ -7,37 +8,82 @@ import {
const schema = {}; const schema = {};
describe("whereCache", () => { describe("whereCache", () => {
test("whereCacheGet - missing cache values", () => { test("whereCacheGet - where query, missing cache values", () => {
expect( expect(
_whereCacheGet({}, schema, "X", { _whereCacheGet({}, schema, "X", {
field: "var", where: {
column: "foo", field: "var",
value: "bar", column: "foo",
value: "bar",
},
}) })
).toEqual([undefined]); ).toEqual([undefined]);
expect( expect(
_whereCacheGet({ X: {} }, schema, "X", { _whereCacheGet({}, schema, "X", {
field: "var", summarize: {
column: "foo", field: "var",
value: "bar", column: "foo",
values: ["bar"],
},
}) })
).toEqual([undefined]); ).toEqual([undefined]);
expect( expect(
_whereCacheGet({ X: { var: new Map() } }, schema, "X", { _whereCacheGet({ where: { X: {} } }, schema, "X", {
field: "var", where: {
column: "foo", field: "var",
value: "bar", column: "foo",
value: "bar",
},
})
).toEqual([undefined]);
expect(
_whereCacheGet({ where: { X: { var: new Map() } } }, schema, "X", {
where: {
field: "var",
column: "foo",
value: "bar",
},
}) })
).toEqual([undefined]); ).toEqual([undefined]);
expect( expect(
_whereCacheGet( _whereCacheGet(
{ X: { var: new Map([["foo", new Map()]]) } }, { where: { X: { var: new Map([["foo", new Map()]]) } } },
schema, schema,
"X", "X",
{ {
where: {
field: "var",
column: "foo",
value: "bar",
},
}
)
).toEqual([undefined]);
});
test("whereCacheGet - summarize query, missing cache values", () => {
expect(
_whereCacheGet({}, schema, "X", {
summarize: {
method: "mean",
field: "var", field: "var",
column: "foo", column: "foo",
value: "bar", values: ["bar"],
},
})
).toEqual([undefined]);
expect(
_whereCacheGet(
{ summarize: { X: { mean: { var: new Map() } } } },
schema,
"X",
{
summarize: {
method: "mean",
field: "var",
column: "foo",
values: ["bar"],
},
} }
) )
).toEqual([undefined]); ).toEqual([undefined]);
@@ -45,140 +91,292 @@ describe("whereCache", () => {
test("whereCacheGet - varied lookups", () => { test("whereCacheGet - varied lookups", () => {
const whereCache = { const whereCache = {
X: { where: {
var: new Map([ X: {
[ var: new Map([
"foo", [
new Map([ "foo",
["bar", [0]], new Map([
["baz", [1, 2]], ["bar", [0]],
["baz", [1, 2]],
]),
],
]),
},
},
summarize: {
X: {
mean: {
var: new Map([
[
"foo",
new Map([
[sha1("bar"), [0]],
[sha1("baz"), [1, 2]],
]),
],
]), ]),
], },
]), },
}, },
}; };
expect( expect(
_whereCacheGet(whereCache, schema, "X", { _whereCacheGet(whereCache, schema, "X", {
field: "var", where: {
column: "foo", field: "var",
value: "bar", column: "foo",
value: "bar",
},
}) })
).toEqual([0]); ).toEqual([0]);
expect( expect(
_whereCacheGet(whereCache, schema, "X", { _whereCacheGet(whereCache, schema, "X", {
field: "var", summarize: {
column: "foo", method: "mean",
value: "baz", field: "var",
column: "foo",
values: ["bar"],
},
})
).toEqual([0]);
expect(
_whereCacheGet(whereCache, schema, "X", {
where: {
field: "var",
column: "foo",
value: "baz",
},
})
).toEqual([1, 2]);
expect(
_whereCacheGet(whereCache, schema, "X", {
summarize: {
method: "mean",
field: "var",
column: "foo",
values: ["baz"],
},
}) })
).toEqual([1, 2]); ).toEqual([1, 2]);
expect(_whereCacheGet(whereCache, schema, "Y", {})).toEqual([undefined]); expect(_whereCacheGet(whereCache, schema, "Y", {})).toEqual([undefined]);
expect( expect(
_whereCacheGet(whereCache, schema, "X", { _whereCacheGet(whereCache, schema, "X", {
field: "whoknows", where: {
column: "whatever", field: "whoknows",
value: "snork", column: "whatever",
value: "snork",
},
}) })
).toEqual([undefined]); ).toEqual([undefined]);
expect( expect(
_whereCacheGet(whereCache, schema, "X", { _whereCacheGet(whereCache, schema, "X", {
field: "var", where: {
column: "whatever", field: "var",
value: "snork", column: "whatever",
value: "snork",
},
}) })
).toEqual([undefined]); ).toEqual([undefined]);
expect( expect(
_whereCacheGet(whereCache, schema, "X", { _whereCacheGet(whereCache, schema, "X", {
field: "var", where: {
column: "foo", field: "var",
value: "snork", column: "foo",
value: "snork",
},
}) })
).toEqual([undefined]); ).toEqual([undefined]);
}); });
test("whereCacheCreate", () => { test("whereCacheCreate, where query", () => {
const query = { const query = {
field: "queryField", where: {
column: "queryColumn", field: "queryField",
value: "queryValue", column: "queryColumn",
value: "queryValue",
},
}; };
const wc = _whereCacheCreate( const wc = _whereCacheCreate(
"field", "field",
{ field: "queryField", column: "queryColumn", value: "queryValue" }, {
where: {
field: "queryField",
column: "queryColumn",
value: "queryValue",
},
},
[0, 1, 2] [0, 1, 2]
); );
expect(wc).toBeDefined(); expect(wc).toBeDefined();
expect(wc).toEqual( expect(wc).toEqual(
expect.objectContaining({ expect.objectContaining({
field: { where: {
queryField: expect.any(Map), field: {
queryField: expect.any(Map),
},
}, },
}) })
); );
expect(wc.field.queryField.has("queryColumn")).toEqual(true); expect(wc.where.field.queryField.has("queryColumn")).toEqual(true);
expect(wc.field.queryField.get("queryColumn")).toBeInstanceOf(Map); expect(wc.where.field.queryField.get("queryColumn")).toBeInstanceOf(Map);
expect(wc.field.queryField.get("queryColumn").has("queryValue")).toEqual( expect(
true wc.where.field.queryField.get("queryColumn").has("queryValue")
); ).toEqual(true);
expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]); expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]);
}); });
test("whereCacheMerge", () => { test("whereCacheCreate, summarize query", () => {
const query = {
summarize: {
method: "method",
field: "queryField",
column: "queryColumn",
values: ["queryValue"],
},
};
const wc = _whereCacheCreate("field", query, [0, 1, 2]);
expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]);
});
test("whereCacheCreate, unknown query type", () => {
expect(_whereCacheCreate("field", { foobar: true }, [1])).toEqual({});
});
test("whereCacheMerge, where queries", () => {
let wc; let wc;
// remember, will mutate dst // remember, will mutate dst
const src = _whereCacheCreate( const src = _whereCacheCreate(
"field", "field",
{ field: "queryField", column: "queryColumn", value: "foo" }, { where: { field: "queryField", column: "queryColumn", value: "foo" } },
["foo"] ["foo"]
); );
const dst1 = _whereCacheCreate( const dst1 = _whereCacheCreate(
"field", "field",
{ field: "queryField", column: "queryColumn", value: "bar" }, { where: { field: "queryField", column: "queryColumn", value: "bar" } },
["dst1"] ["dst1"]
); );
wc = _whereCacheMerge(dst1, src); wc = _whereCacheMerge(dst1, src);
expect( expect(
_whereCacheGet(wc, schema, "field", { _whereCacheGet(wc, schema, "field", {
field: "queryField", where: {
column: "queryColumn", field: "queryField",
value: "foo", column: "queryColumn",
value: "foo",
},
}) })
).toEqual(["foo"]); ).toEqual(["foo"]);
expect( expect(
_whereCacheGet(wc, schema, "field", { _whereCacheGet(wc, schema, "field", {
field: "queryField", where: {
column: "queryColumn", field: "queryField",
value: "bar", column: "queryColumn",
value: "bar",
},
}) })
).toEqual(["dst1"]); ).toEqual(["dst1"]);
const dst2 = _whereCacheCreate( const dst2 = _whereCacheCreate(
"field", "field",
{ field: "queryField", column: "queryColumn", value: "bar" }, { where: { field: "queryField", column: "queryColumn", value: "bar" } },
["dst2"] ["dst2"]
); );
wc = _whereCacheMerge(dst2, dst1, src); wc = _whereCacheMerge(dst2, dst1, src);
expect( expect(
_whereCacheGet(wc, schema, "field", { _whereCacheGet(wc, schema, "field", {
field: "queryField", where: {
column: "queryColumn", field: "queryField",
value: "foo", column: "queryColumn",
value: "foo",
},
}) })
).toEqual(["foo"]); ).toEqual(["foo"]);
expect( expect(
_whereCacheGet(wc, schema, "field", { _whereCacheGet(wc, schema, "field", {
field: "queryField", where: {
column: "queryColumn", field: "queryField",
value: "bar", column: "queryColumn",
value: "bar",
},
}) })
).toEqual(["dst1"]); ).toEqual(["dst1"]);
wc = _whereCacheMerge({}, src); wc = _whereCacheMerge({}, src);
expect(wc).toEqual(src); expect(wc).toEqual(src);
wc = _whereCacheMerge({ field: { queryField: new Map() } }, src); wc = _whereCacheMerge({ where: { field: { queryField: new Map() } } }, src);
expect(wc).toEqual(src); expect(wc).toEqual(src);
}); });
test("whereCacheMerge, mixed queries", () => {
const wc = _whereCacheMerge(
_whereCacheCreate(
"field",
{
where: {
field: "queryField",
column: "queryColumn",
value: "foo",
},
},
["a"]
),
_whereCacheCreate(
"field",
{
summarize: {
method: "mean",
field: "queryField",
column: "queryColumn",
values: ["foo", "bar", "baz"],
},
},
["b"]
)
);
expect(
_whereCacheGet(wc, schema, "field", {
where: {
field: "queryField",
column: "queryColumn",
value: "foo",
},
})
).toEqual(["a"]);
expect(
_whereCacheGet(wc, schema, "field", {
summarize: {
method: "mean",
field: "queryField",
column: "queryColumn",
values: ["foo", "bar", "baz"],
},
})
).toEqual(["b"]);
expect(
_whereCacheGet(wc, schema, "field", {
where: {
field: "queryField",
column: "queryColumn",
value: "does-not-exist",
},
})
).toEqual([undefined]);
expect(
_whereCacheGet(wc, schema, "field", {
summarize: {
method: "no-such-method",
field: "queryField",
column: "queryColumn",
values: ["does-not-exist"],
},
})
).toEqual([undefined]);
});
}); });
+27362 -8536
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -70,6 +70,7 @@
"regenerator-runtime": "^0.13.7", "regenerator-runtime": "^0.13.7",
"regl": "^1.6.1", "regl": "^1.6.1",
"script-ext-html-webpack-plugin": "^2.1.4", "script-ext-html-webpack-plugin": "^2.1.4",
"sha1": "^1.1.1",
"tinyqueue": "^2.0.3", "tinyqueue": "^2.0.3",
"webpack-merge": "^5.0.9", "webpack-merge": "^5.0.9",
"whatwg-fetch": "^3.2.0" "whatwg-fetch": "^3.2.0"
+2 -2
View File
@@ -396,9 +396,9 @@ export const saveGenesetsAction = () => async (dispatch, getState) => {
const { lastTid, genesets } = state.genesets; const { lastTid, genesets } = state.genesets;
const genesetsAreAvailable = const genesetsAreAvailable =
config?.parameters?.["annotations_genesets"] ?? false; config?.parameters?.annotations_genesets ?? false;
const genesetsReadonly = const genesetsReadonly =
config?.parameters?.["annotations_genesets_readonly"] ?? true; config?.parameters?.annotations_genesets_readonly ?? true;
if (!genesetsAreAvailable || genesetsReadonly) { if (!genesetsAreAvailable || genesetsReadonly) {
// our non-save was completed! // our non-save was completed!
return dispatch({ return dispatch({
+126
View File
@@ -0,0 +1,126 @@
/*
Action creators for gene sets
Primarily used to keep the crossfilter and underlying data in sync with the UI.
The behavior manifest in these action creators:
Delete a gene set, will
* drop index & clear selection state on the gene set summary
* drop index & clear selection state of each gene in the geneset
Delete a gene from a gene set, will:
* drop index & clear selection state on the gene set summary
* drop index & clear selection state on the gene
Add a gene to a gene set, will:
* drop index & clear selection state on the gene set summary
* will NOT touch the selection state for the gene
Note that crossfilter indices are lazy created, as needed.
*/
export const genesetDelete = (genesetName) => (dispatch, getState) => {
const state = getState();
const { genesets } = state;
const gs = genesets?.genesets?.get(genesetName) ?? {};
const geneSymbols = Array.from(gs.genes.keys());
const obsCrossfilter = dropGeneset(dispatch, state, genesetName, geneSymbols);
dispatch({
type: "geneset: delete",
genesetName,
obsCrossfilter,
annoMatrix: obsCrossfilter.annoMatrix,
});
};
export const genesetAddGenes = (genesetName, genes) => (dispatch, getState) => {
const state = getState();
const { obsCrossfilter: prevObsCrossfilter } = state;
const obsCrossfilter = dropGenesetSummaryDimension(
prevObsCrossfilter,
state,
genesetName
);
dispatch({
type: "continuous metadata histogram cancel",
continuousNamespace: { isGeneSetSummary: true },
selection: genesetName,
});
return dispatch({
type: "geneset: add genes",
genesetName,
genes,
obsCrossfilter,
annoMatrix: obsCrossfilter.annoMatrix,
});
};
export const genesetDeleteGenes = (genesetName, geneSymbols) => (
dispatch,
getState
) => {
const state = getState();
const obsCrossfilter = dropGeneset(dispatch, state, genesetName, geneSymbols);
return dispatch({
type: "geneset: delete genes",
genesetName,
geneSymbols,
obsCrossfilter,
annoMatrix: obsCrossfilter.annoMatrix,
});
};
/*
Private
*/
function dropGenesetSummaryDimension(obsCrossfilter, state, genesetName) {
const { annoMatrix, genesets } = state;
const varIndex = annoMatrix.schema.annotations?.var?.index;
const gs = genesets?.genesets?.get(genesetName) ?? {};
const genes = Array.from(gs.genes.keys());
const query = {
summarize: {
method: "mean",
field: "var",
column: varIndex,
values: genes,
},
};
return obsCrossfilter.dropDimension("X", query);
}
function dropGeneDimension(obsCrossfilter, state, gene) {
const { annoMatrix } = state;
const varIndex = annoMatrix.schema.annotations?.var?.index;
const query = {
where: {
field: "var",
column: varIndex,
value: gene,
},
};
return obsCrossfilter.dropDimension("X", query);
}
function dropGeneset(dispatch, state, genesetName, geneSymbols) {
const { obsCrossfilter: prevObsCrossfilter } = state;
const obsCrossfilter = geneSymbols.reduce(
(crossfilter, gene) => dropGeneDimension(crossfilter, state, gene),
dropGenesetSummaryDimension(prevObsCrossfilter, state, genesetName)
);
dispatch({
type: "continuous metadata histogram cancel",
continuousNamespace: { isGeneSetSummary: true },
selection: genesetName,
});
geneSymbols.forEach((g) =>
dispatch({
type: "continuous metadata histogram cancel",
continuousNamespace: { isUserDefined: true },
selection: g,
})
);
return obsCrossfilter;
}
+6 -2
View File
@@ -13,6 +13,7 @@ import * as selnActions from "./selection";
import * as annoActions from "./annotation"; import * as annoActions from "./annotation";
import * as viewActions from "./viewStack"; import * as viewActions from "./viewStack";
import * as embActions from "./embedding"; import * as embActions from "./embedding";
import * as genesetActions from "./geneset";
/* /*
return promise fetching user-configured colors return promise fetching user-configured colors
@@ -58,7 +59,7 @@ async function genesetsFetch(dispatch, config) {
genesets: [], genesets: [],
tid: 0, tid: 0,
}; };
if (config?.parameters?.["annotations_genesets"] ?? false) { if (config?.parameters?.annotations_genesets ?? false) {
fetchJson("genesets").then((response) => { fetchJson("genesets").then((response) => {
dispatch({ dispatch({
type: "geneset: initial load", type: "geneset: initial load",
@@ -111,7 +112,7 @@ const doInitialDataLoad = () =>
}); });
dispatch({ type: "initial data load complete" }); dispatch({ type: "initial data load complete" });
const defaultEmbedding = config?.parameters?.["default_embedding"]; const defaultEmbedding = config?.parameters?.default_embedding;
const layoutSchema = schema?.schema?.layout?.obs ?? []; const layoutSchema = schema?.schema?.layout?.obs ?? [];
if ( if (
defaultEmbedding && defaultEmbedding &&
@@ -269,4 +270,7 @@ export default {
needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations, needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations,
layoutChoiceAction: embActions.layoutChoiceAction, layoutChoiceAction: embActions.layoutChoiceAction,
setCellSetFromSelection: selnActions.setCellSetFromSelection, setCellSetFromSelection: selnActions.setCellSetFromSelection,
genesetDelete: genesetActions.genesetDelete,
genesetAddGenes: genesetActions.genesetAddGenes,
genesetDeleteGenes: genesetActions.genesetDeleteGenes,
}; };
+18 -26
View File
@@ -12,6 +12,7 @@ import {
import { indexEntireSchema } from "../util/stateManager/schemaHelpers"; import { indexEntireSchema } from "../util/stateManager/schemaHelpers";
import { _whereCacheGet, _whereCacheMerge } from "./whereCache"; import { _whereCacheGet, _whereCacheMerge } from "./whereCache";
import _shallowClone from "./clone"; import _shallowClone from "./clone";
import { _queryValidate, _queryCacheKey } from "./query";
const _dataframeCache = dataframeMemo(128); const _dataframeCache = dataframeMemo(128);
@@ -181,7 +182,7 @@ export default class AnnoMatrix {
Returns a Promise for the query result, which will resolve to a dataframe. Returns a Promise for the query result, which will resolve to a dataframe.
Field must be one of the matrix fields: 'obs', 'var', 'X', 'emb'. Value Field must be one of the matrix fields: 'obs', 'var', 'X', 'emb'. Value
represents the underlying object upon which the query is occuring. represents the underlying object upon which the query is occurring.
Query is one of: Query is one of:
* a string, representing a single column name from the field, eg, * a string, representing a single column name from the field, eg,
@@ -197,12 +198,6 @@ export default class AnnoMatrix {
field/column, similar to a join. Currently only supported on the var field/column, similar to a join. Currently only supported on the var
dimension, allowing query of X columns by var value (eg, gene name) dimension, allowing query of X columns by var value (eg, gene name)
The query filter is a single value filter:
{ "field name": [
{name: "column name", values: [ list of values ]}
]}
One and only one value filter is allowed in a value query.
Examples: Examples:
1. Fetch the "n_genes" column the "obs": 1. Fetch the "n_genes" column the "obs":
@@ -220,7 +215,9 @@ export default class AnnoMatrix {
value "TYMP" in the var index. value "TYMP" in the var index.
fetch("X", { fetch("X", {
where: {field: "var", column: this.schema.annotations.var.index, value: "TYMP"} where: {
field: "var", column: this.schema.annotations.var.index, value: "TYMP"
}
}) })
In AnnData & Pandas DataFrame API, this is equivalent to: In AnnData & Pandas DataFrame API, this is equivalent to:
@@ -410,6 +407,14 @@ export default class AnnoMatrix {
_subclassResponsibility(); _subclassResponsibility();
} }
getCacheKeys(field, query) {
/*
Return cache keys for columns associated with this query. May return
[unknown] if no keys are known (ie, nothing is or was cached).
*/
return _whereCacheGet(this._whereCache, this.schema, field, query);
}
/** /**
** Private interfaces below. ** Private interfaces below.
**/ **/
@@ -427,6 +432,7 @@ export default class AnnoMatrix {
async _fetch(field, q) { async _fetch(field, q) {
if (!AnnoMatrix.fields().includes(field)) return undefined; if (!AnnoMatrix.fields().includes(field)) return undefined;
const queries = Array.isArray(q) ? q : [q]; const queries = Array.isArray(q) ? q : [q];
queries.forEach(_queryValidate);
/* find cached columns we need, and GC the rest */ /* find cached columns we need, and GC the rest */
const cachedColumns = this._resolveCachedQueries(field, queries); const cachedColumns = this._resolveCachedQueries(field, queries);
@@ -507,7 +513,7 @@ export default class AnnoMatrix {
* obs, var and emb do not grow without bounds, and are needed constantly * obs, var and emb do not grow without bounds, and are needed constantly
for rendering. for rendering.
a) There is no upside to GC'ing these in the base (loader) a) There is no upside to GC'ing these in the base (loader)
b) The undo/redo cache can hold a large number in views, which is worht GC'ing b) The undo/redo cache can hold a large number in views, which is worth GC'ing
* X is often much larger than memory, and the UI allows add/del from * X is often much larger than memory, and the UI allows add/del from
this. Most of the GC potential is here in both the base and views. this. Most of the GC potential is here in both the base and views.
@@ -522,7 +528,7 @@ export default class AnnoMatrix {
as much of the cache is pinned by that data structure. as much of the cache is pinned by that data structure.
*/ */
_gcField(field, isHot, pinnedColumns) { _gcField(field, isHot, pinnedColumns) {
const maxColumns = isHot ? 256 : 10; // maybe to aggessive? const maxColumns = isHot ? 256 : 10; // maybe to aggressive?
const cache = this._cache[field]; const cache = this._cache[field];
if (cache.colIndex.size() < maxColumns) return; // trivial rejection if (cache.colIndex.size() < maxColumns) return; // trivial rejection
@@ -590,23 +596,18 @@ export default class AnnoMatrix {
called each time a query is performed, allowing the gc to update any bookkeeping called each time a query is performed, allowing the gc to update any bookkeeping
information. Currently, this is just a simple last-fetched timestamp, stored information. Currently, this is just a simple last-fetched timestamp, stored
in a Map. in a Map.
Map objects preserve order of insertion. This is leveraged as a cheap way to
do LRU, by removing and re-inserting keys. IMPORTANT: the cleanup code assumes
the map insertion order is least-recently-used first.
*/ */
const cols = dataframe.colIndex.labels(); const cols = dataframe.colIndex.labels();
const { _gcInfo } = this; const { _gcInfo } = this;
const now = Date.now(); const now = Date.now();
cols.forEach((c) => { cols.forEach((c) => {
// gcInfo.delete(c);
_gcInfo.set(_columnCacheKey(field, c), now); _gcInfo.set(_columnCacheKey(field, c), now);
}); });
} }
/** /**
Cloning sublcass protocol - we rely in cloning to preserve immutable Cloning subclass protocol - we rely in cloning to preserve immutable
symantics while not causing races or other side effects in internal semantics while not causing races or other side effects in internal
cache management. cache management.
Subclasses must override _cloneDeeper() if they have state which requires Subclasses must override _cloneDeeper() if they have state which requires
@@ -639,15 +640,6 @@ export default class AnnoMatrix {
/* /*
private utility functions below private utility functions below
*/ */
function _queryCacheKey(field, query) {
if (typeof query === "object") {
const { field: queryField, column: queryColumn, value: queryValue } = query;
return `${field}/${queryField}/${queryColumn}/${queryValue}`;
}
return `${field}/${query}`;
}
function _columnCacheKey(field, column) { function _columnCacheKey(field, column) {
return `${field}/${column}`; return `${field}/${column}`;
} }
+18
View File
@@ -123,6 +123,24 @@ export default class AnnoMatrixObsCrossfilter {
return new AnnoMatrixObsCrossfilter(annoMatrix, this.obsCrossfilter); return new AnnoMatrixObsCrossfilter(annoMatrix, this.obsCrossfilter);
} }
/**
* Drop the crossfilter dimension. Do not change the annoMatrix. Useful when we
* want to stop trackin the selection state, but aren't sure we want to blow the
* annomatrix cache.
*/
dropDimension(field, query) {
const { annoMatrix } = this;
let { obsCrossfilter } = this;
const keys = annoMatrix
.getCacheKeys(field, query)
.filter((k) => k !== undefined);
const dimName = _dimensionName(field, keys);
if (obsCrossfilter.hasDimension(dimName)) {
obsCrossfilter = obsCrossfilter.delDimension(dimName);
}
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
}
/** /**
Selection state - API is identical to ImmutableTypedCrossfilter, as these Selection state - API is identical to ImmutableTypedCrossfilter, as these
are just wrappers to lazy create indices. are just wrappers to lazy create indices.
+1 -1
View File
@@ -1,4 +1,4 @@
export { doBinaryRequest } from "../util/actionHelpers"; export { doBinaryRequest, doFetch } from "../util/actionHelpers";
/* double URI encode - needed for query-param filters */ /* double URI encode - needed for query-param filters */
export function _dubEncURIComp(s) { export function _dubEncURIComp(s) {
+69 -28
View File
@@ -1,4 +1,4 @@
import { doBinaryRequest, _dubEncURIComp } from "./fetchHelpers"; import { doBinaryRequest, doFetch } from "./fetchHelpers";
import { matrixFBSToDataframe } from "../util/stateManager/matrix"; import { matrixFBSToDataframe } from "../util/stateManager/matrix";
import { _getColumnSchema, _normalizeCategoricalSchema } from "./schema"; import { _getColumnSchema, _normalizeCategoricalSchema } from "./schema";
import { import {
@@ -12,6 +12,13 @@ import { isArrayOrTypedArray } from "../util/typeHelpers";
import { _whereCacheCreate } from "./whereCache"; import { _whereCacheCreate } from "./whereCache";
import AnnoMatrix from "./annoMatrix"; import AnnoMatrix from "./annoMatrix";
import PromiseLimit from "../util/promiseLimit"; import PromiseLimit from "../util/promiseLimit";
import {
_expectSimpleQuery,
_expectComplexQuery,
_urlEncodeLabelQuery,
_urlEncodeComplexQuery,
_hashStringValues,
} from "./query";
const promiseThrottle = new PromiseLimit(5); const promiseThrottle = new PromiseLimit(5);
@@ -223,27 +230,23 @@ export default class AnnoMatrixLoader extends AnnoMatrix {
/* /*
_doLoad - evaluates the query against the field. Returns: _doLoad - evaluates the query against the field. Returns:
* whereCache update: column query map mapping the query to the column labels * whereCache update: column query map mapping the query to the column labels
* Dataframe containing the new colums (one per dimension) * Dataframe containing the new columns (one per dimension)
*/ */
let urlQuery; let doRequest;
let urlBase;
let priority = 10; // default fetch priority let priority = 10; // default fetch priority
switch (field) { switch (field) {
case "obs": case "obs":
case "var": { case "var": {
urlBase = `${this.baseURL}annotations/${field}`; doRequest = _obsOrVarLoader(this.baseURL, field, query);
urlQuery = _encodeQuery("annotation-name", query);
break; break;
} }
case "X": { case "X": {
urlBase = `${this.baseURL}data/var`; doRequest = _XLoader(this.baseURL, field, query);
urlQuery = _encodeQuery(undefined, query);
break; break;
} }
case "emb": { case "emb": {
urlBase = `${this.baseURL}layout/obs`; doRequest = _embLoader(this.baseURL, field, query);
urlQuery = _encodeQuery("layout-name", query);
priority = 0; // high prio load for embeddings priority = 0; // high prio load for embeddings
break; break;
} }
@@ -251,12 +254,7 @@ export default class AnnoMatrixLoader extends AnnoMatrix {
throw new Error("Unknown field name"); throw new Error("Unknown field name");
} }
const url = `${urlBase}?${urlQuery}`; const buffer = await promiseThrottle.priorityAdd(priority, doRequest);
const buffer = await promiseThrottle.priorityAdd(
priority,
doBinaryRequest,
url
);
const result = matrixFBSToDataframe(buffer); const result = matrixFBSToDataframe(buffer);
if (!result || result.isEmpty()) throw Error("Unknown field/col"); if (!result || result.isEmpty()) throw Error("Unknown field/col");
@@ -267,7 +265,7 @@ export default class AnnoMatrixLoader extends AnnoMatrix {
); );
if (field === "obs") { if (field === "obs") {
/* cough, cough - see comment on method */ /* cough, cough - see comment on the function called */
_normalizeCategoricalSchema( _normalizeCategoricalSchema(
this.schema.annotations.obsByName[query], this.schema.annotations.obsByName[query],
result.col(query) result.col(query)
@@ -282,17 +280,6 @@ export default class AnnoMatrixLoader extends AnnoMatrix {
Utility functions below Utility functions below
*/ */
function _encodeQuery(colKey, q) {
if (typeof q === "object") {
const { field: queryField, column: queryColumn, value: queryValue } = q;
return `${_dubEncURIComp(queryField)}:${_dubEncURIComp(
queryColumn
)}=${_dubEncURIComp(queryValue)}`;
}
if (!colKey) throw new Error("Unsupported query by name");
return `${colKey}=${encodeURIComponent(q)}`;
}
function _writableCheck(colSchema) { function _writableCheck(colSchema) {
if (!colSchema?.writable) { if (!colSchema?.writable) {
throw new Error("Unknown or readonly obs column"); throw new Error("Unknown or readonly obs column");
@@ -305,3 +292,57 @@ function _writableCategoryTypeCheck(colSchema) {
throw new Error("column must be categorical"); throw new Error("column must be categorical");
} }
} }
function _embLoader(baseURL, _field, query) {
_expectSimpleQuery(query);
const urlBase = `${baseURL}layout/obs`;
const urlQuery = _urlEncodeLabelQuery("layout-name", query);
const url = `${urlBase}?${urlQuery}`;
return () => doBinaryRequest(url);
}
function _obsOrVarLoader(baseURL, field, query) {
_expectSimpleQuery(query);
const urlBase = `${baseURL}annotations/${field}`;
const urlQuery = _urlEncodeLabelQuery("annotation-name", query);
const url = `${urlBase}?${urlQuery}`;
return () => doBinaryRequest(url);
}
function _XLoader(baseURL, field, query) {
_expectComplexQuery(query);
if (query.where) {
const urlBase = `${baseURL}data/var`;
const urlQuery = _urlEncodeComplexQuery(query);
const url = `${urlBase}?${urlQuery}`;
return () => doBinaryRequest(url);
}
if (query.summarize) {
const urlBase = `${baseURL}summarize/var`;
const urlQuery = _urlEncodeComplexQuery(query);
if (urlBase.length + urlQuery.length < 2000) {
const url = `${urlBase}?${urlQuery}`;
return () => doBinaryRequest(url);
}
const url = `${urlBase}?key=${_hashStringValues([urlQuery])}`;
return async () => {
const res = await doFetch(url, {
method: "POST",
body: urlQuery,
headers: new Headers({
Accept: "application/octet-stream",
"Content-Type": "application/x-www-form-urlencoded",
}),
});
return res.arrayBuffer();
};
}
throw new Error("Unknown query structure");
}
+126
View File
@@ -0,0 +1,126 @@
import sha1 from "sha1";
import { _dubEncURIComp } from "./fetchHelpers";
/**
* Query utilities, mostly for debugging support and validation.
*/
/**
* Normalize & error check the query.
* @param {object | string} query - the query
* @returns {object | string} - the normalized query
*/
export function _queryValidate(query) {
if (typeof query !== "object") return query;
if (query.where && query.summarize)
throw new Error("query may not specify both where and summarize");
if (query.where) {
const {
field: queryField,
column: queryColumn,
value: queryValue,
} = query.where;
if (!queryField || !queryColumn || !queryValue)
throw new Error("Incomplete where query");
return query;
}
if (query.summarize) {
const {
field: queryField,
column: queryColumn,
values: queryValues,
} = query.summarize;
if (!queryField || !queryColumn || !queryValues)
throw new Error("Incomplete where query");
if (!Array.isArray(queryValues))
throw new Error("Summarize query values must be an array");
return query;
}
throw new Error("query must specify one of where or summarize");
}
export function _expectSimpleQuery(query) {
if (typeof query === "object") throw new Error("expected simple query");
}
export function _expectComplexQuery(query) {
if (typeof query !== "object") throw new Error("expected complex query");
}
/**
* Generate a unique key which can be used to reference this query.
*
* @param {string} field
* @param {string|object} query
* @returns the key
*/
export function _queryCacheKey(field, query) {
if (typeof query === "object") {
// complex query
if (query.where) {
const {
field: queryField,
column: queryColumn,
value: queryValue,
} = query.where;
return `${field}/${queryField}/${queryColumn}/${queryValue}`;
}
if (query.summarize) {
const {
method,
field: queryField,
column: queryColumn,
values: queryValues,
} = query.summarize;
return `${field}/${method}/${queryField}/${queryColumn}/${queryValues.join(
","
)}`;
}
throw new Error("Unrecognized complex query type");
}
// simple query
return `${field}/${query}`;
}
function _urlEncodeWhereQuery(q) {
const { field: queryField, column: queryColumn, value: queryValue } = q;
return `${_dubEncURIComp(queryField)}:${_dubEncURIComp(
queryColumn
)}=${_dubEncURIComp(queryValue)}`;
}
function _urlEncodeSummarizeQuery(q) {
const { method, field, column, values } = q;
const filter = values
.map((value) => _urlEncodeWhereQuery({ field, column, value }))
.join("&");
return `method=${method}&${filter}`;
}
export function _urlEncodeComplexQuery(q) {
if (typeof q === "object") {
if (q.where) {
return _urlEncodeWhereQuery(q.where);
}
if (q.summarize) {
return _urlEncodeSummarizeQuery(q.summarize);
}
}
throw new Error("Unrecognized complex query type");
}
export function _urlEncodeLabelQuery(colKey, q) {
if (!colKey) throw new Error("Unsupported query by name");
if (typeof q !== "string") throw new Error("Query must be a simple label.");
return `${colKey}=${encodeURIComponent(q)}`;
}
/**
* Generate the column key the server will send us for this query.
*/
export function _hashStringValues(arrayOfString) {
const hash = sha1(arrayOfString.join(""));
return hash;
}
+1 -1
View File
@@ -29,7 +29,7 @@ export function _getColumnSchema(schema, field, col) {
export function _getColumnDimensionNames(schema, field, col) { export function _getColumnDimensionNames(schema, field, col) {
/* /*
field/col may be an alias for multiple columns. Currently used to map ND field/col may be an alias for multiple columns. Currently used to map ND
values to 1D dataframe columns for embeddings/layout. Signfied by the presence values to 1D dataframe columns for embeddings/layout. Signified by the presence
of the "dims" value in the schema. of the "dims" value in the schema.
*/ */
const colSchema = _getColumnSchema(schema, field, col); const colSchema = _getColumnSchema(schema, field, col);
+135 -51
View File
@@ -1,27 +1,55 @@
/* /*
Private support functions. Private support functions.
Support for a "where" query, eg, This implements a query resolver cache, mapping a query onto the column labels
resolved by that query. These labels are then used to manage the acutal data cache,
which stores data by the resolved label.
{ where: { field: "var", column: "gene", value: "FOXP2" }} There are three query forms:
* primitive (string, number) - which is just reference the column label of same value
* where query (object) - eg, { where: { field: "var", column: "gene", value: "FOXP2" }}
* summary query (object) - eg, { summarize: { method: "mean", field: "var", column: "gene", values: ["FOXP2", "GNE", "F5"]}}
These evaluate to a given column label. These queries all resolve to one or more column labels on a field. This
cache maintains a record of this, allowing direct access to the data caches
The "where cache" is a map that saves evaluated queries and points without a server round-trip.
to the column label they resolve to.
Data structure, using X as the example field being queried, and var as
the index.
The data structure for where queries, the following query against X as an example:
{ where: { field: "var", column: "column_label_in_var", value: "value_in_var_column" } }
results in the following cached entry:
{ {
X: { where: {
var: Map( X: {
column_label_in_var => Map(value_in_var_column => [column_label_in_X, ...]) var: Map(
) column_label_in_var => Map(
} value_in_var_column => [column_label_in_X, ...]
)
)
}
},
summarize: {},
}
And for summarize queries, for the following summary on X:
{ summarize: { method: "mean", field: "var", column: "gene", values: ["G1", "G2"]}}
creates a cache entry of:
{
where: {},
summarize: {
X: {
mean: {
var: Map(
"gene" => Map(
"G1,G2" => [summary_column_label, ...]
)
)
}
},
},
} }
*/ */
import { _getColumnDimensionNames } from "./schema"; import { _getColumnDimensionNames } from "./schema";
import { _hashStringValues } from "./query";
export function _whereCacheGet(whereCache, schema, field, query) { export function _whereCacheGet(whereCache, schema, field, query) {
/* /*
@@ -31,20 +59,30 @@ export function _whereCacheGet(whereCache, schema, field, query) {
*/ */
if (typeof query === "object") { if (typeof query === "object") {
const { field: queryField, column: queryColumn, value: queryValue } = query; if (query.where) {
const {
const columnMap = whereCache?.[field]?.[queryField]; field: queryField,
if (columnMap === undefined) return [undefined]; column: queryColumn,
value: queryValue,
const valueMap = columnMap.get(queryColumn); } = query.where;
if (valueMap === undefined) return [undefined]; const columnMap = whereCache?.where?.[field]?.[queryField];
return columnMap?.get(queryColumn)?.get(queryValue) ?? [undefined];
const columnLabels = valueMap.get(queryValue); }
return columnLabels === undefined ? [undefined] : columnLabels; if (query.summarize) {
const {
method,
field: queryField,
column: queryColumn,
values: queryValues,
} = query.summarize;
const columnMap = whereCache?.summarize?.[field]?.[method]?.[queryField];
const queryValueHash = _hashStringValues(queryValues);
return columnMap?.get(queryColumn)?.get(queryValueHash) ?? [undefined];
}
return [undefined];
} }
const colDims = _getColumnDimensionNames(schema, field, query); return _getColumnDimensionNames(schema, field, query) ?? [undefined];
return colDims === undefined ? [undefined] : colDims;
} }
export function _whereCacheCreate(field, query, columnLabels) { export function _whereCacheCreate(field, query, columnLabels) {
@@ -53,40 +91,86 @@ export function _whereCacheCreate(field, query, columnLabels) {
*/ */
if (typeof query !== "object") return null; if (typeof query !== "object") return null;
const { field: queryField, column: queryColumn, value: queryValue } = query; if (query.where) {
const whereCache = { const {
[field]: { field: queryField,
[queryField]: new Map([ column: queryColumn,
[queryColumn, new Map([[queryValue, columnLabels]])], value: queryValue,
]), } = query.where;
}, return {
}; where: {
return whereCache; [field]: {
[queryField]: new Map([
[queryColumn, new Map([[queryValue, columnLabels]])],
]),
},
},
};
}
if (query.summarize) {
const {
method,
field: queryField,
column: queryColumn,
values: queryValues,
} = query.summarize;
const queryValueHash = _hashStringValues(queryValues);
return {
summarize: {
[field]: {
[method]: {
[queryField]: new Map([
[queryColumn, new Map([[queryValueHash, columnLabels]])],
]),
},
},
},
};
}
// oops, not sure what that query is!
return {};
}
function __mergeQueries(dst, src) {
for (const [queryField, columnMap] of Object.entries(src)) {
dst[queryField] = dst[queryField] || new Map();
for (const [queryColumn, valueMap] of columnMap) {
if (!dst[queryField].has(queryColumn))
dst[queryField].set(queryColumn, new Map());
for (const [queryValue, columnLabels] of valueMap) {
dst[queryField].get(queryColumn).set(queryValue, columnLabels);
}
}
}
} }
function __whereCacheMerge(dst, src) { function __whereCacheMerge(dst, src) {
/* /*
merge src into dst (modifies dst) merge src into dst (modifies dst)
*/ */
if (!dst) dst = {};
if (!src || typeof src !== "object") return dst; if (!src || typeof src !== "object") return dst;
Object.entries(src).forEach(([field, query]) => {
if (!Object.prototype.hasOwnProperty.call(dst, field)) dst[field] = {}; if (src.where) {
Object.entries(query).forEach(([queryField, columnMap]) => { dst.where = dst.where || {};
if (!Object.prototype.hasOwnProperty.call(dst[field], queryField)) for (const [field, query] of Object.entries(src.where)) {
dst[field][queryField] = new Map(); dst.where[field] = dst.where[field] || {};
columnMap.forEach((valueMap, queryColumn) => { __mergeQueries(dst.where[field], query);
if (!dst[field][queryField].has(queryColumn)) }
dst[field][queryField].set(queryColumn, new Map()); }
valueMap.forEach((columnLabels, queryValue) => { if (src.summarize) {
dst[field][queryField].get(queryColumn).set(queryValue, columnLabels); dst.summarize = dst.summarize || {};
}); for (const [field, method] of Object.entries(src.summarize)) {
}); dst.summarize[field] = dst.summarize[field] || {};
}); for (const [methodName, query] of Object.entries(method)) {
}); dst.summarize[field][methodName] =
dst.summarize[field][methodName] || {};
__mergeQueries(dst.summarize[field][methodName], query);
}
}
}
return dst; return dst;
} }
export function _whereCacheMerge(...caches) { export function _whereCacheMerge(...caches) {
return caches.reduce((dst, src) => __whereCacheMerge(dst, src), {}); return caches.reduce(__whereCacheMerge, {});
} }
@@ -18,7 +18,7 @@ import {
userInfo: state.userInfo, userInfo: state.userInfo,
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
writableGenesetsEnabled: !( writableGenesetsEnabled: !(
state.config?.parameters?.["annotations_genesets_readonly"] ?? true state.config?.parameters?.annotations_genesets_readonly ?? true
), ),
})) }))
class FilenameDialog extends React.Component { class FilenameDialog extends React.Component {
+1 -1
View File
@@ -11,7 +11,7 @@ import FilenameDialog from "./filenameDialog";
error: state.autosave?.error, error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
writableGenesetsEnabled: !( writableGenesetsEnabled: !(
state.config?.parameters?.["annotations_genesets_readonly"] ?? true state.config?.parameters?.annotations_genesets_readonly ?? true
), ),
annoMatrix: state.annoMatrix, annoMatrix: state.annoMatrix,
genesets: state.genesets, genesets: state.genesets,
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect, shallowEqual } from "react-redux";
import * as d3 from "d3"; import * as d3 from "d3";
import Async from "react-async"; import Async from "react-async";
import memoize from "memoize-one"; import memoize from "memoize-one";
@@ -14,9 +14,9 @@ import StillLoading from "./loading";
import ErrorLoading from "./error"; import ErrorLoading from "./error";
@connect((state, ownProps) => { @connect((state, ownProps) => {
const { isObs, isUserDefined, isDiffExp, field } = ownProps; const { isObs, isUserDefined, isDiffExp, isGeneSetSummary, field } = ownProps;
const myName = makeContinuousDimensionName( const myName = makeContinuousDimensionName(
{ isObs, isUserDefined, isDiffExp }, { isObs, isUserDefined, isDiffExp, isGeneSetSummary },
field field
); );
return { return {
@@ -28,6 +28,10 @@ import ErrorLoading from "./error";
}; };
}) })
class HistogramBrush extends React.PureComponent { class HistogramBrush extends React.PureComponent {
static watchAsync(props, prevProps) {
return !shallowEqual(props.watchProps, prevProps.watchProps);
}
/* memoized closure to prevent HistogramHeader unecessary repaint */ /* memoized closure to prevent HistogramHeader unecessary repaint */
handleColorAction = memoize((dispatch) => (field, isObs) => { handleColorAction = memoize((dispatch) => (field, isObs) => {
if (isObs) { if (isObs) {
@@ -71,7 +75,14 @@ class HistogramBrush extends React.PureComponent {
onBrush = (selection, x, eventType) => { onBrush = (selection, x, eventType) => {
const type = `continuous metadata histogram ${eventType}`; const type = `continuous metadata histogram ${eventType}`;
return () => { return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props; const {
dispatch,
field,
isObs,
isUserDefined,
isDiffExp,
isGeneSetSummary,
} = this.props;
// ignore programmatically generated events // ignore programmatically generated events
if (!d3.event.sourceEvent) return; if (!d3.event.sourceEvent) return;
@@ -88,6 +99,7 @@ class HistogramBrush extends React.PureComponent {
isObs, isObs,
isUserDefined, isUserDefined,
isDiffExp, isDiffExp,
isGeneSetSummary,
}, },
}; };
dispatch( dispatch(
@@ -98,7 +110,14 @@ class HistogramBrush extends React.PureComponent {
onBrushEnd = (selection, x) => { onBrushEnd = (selection, x) => {
return () => { return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props; const {
dispatch,
field,
isObs,
isUserDefined,
isDiffExp,
isGeneSetSummary,
} = this.props;
const minAllowedBrushSize = 10; const minAllowedBrushSize = 10;
const smallAmountToAvoidInfiniteLoop = 0.1; const smallAmountToAvoidInfiniteLoop = 0.1;
@@ -138,6 +157,7 @@ class HistogramBrush extends React.PureComponent {
isObs, isObs,
isUserDefined, isUserDefined,
isDiffExp, isDiffExp,
isGeneSetSummary,
}, },
}; };
dispatch( dispatch(
@@ -300,19 +320,37 @@ class HistogramBrush extends React.PureComponent {
} }
createQuery() { createQuery() {
const { isObs, field, annoMatrix } = this.props; const { isObs, isGeneSetSummary, field, setGenes, annoMatrix } = this.props;
const { schema } = annoMatrix; const { schema } = annoMatrix;
if (isObs) { if (isObs) {
return ["obs", field]; return ["obs", field];
} }
const varIndex = schema?.annotations?.var?.index; const varIndex = schema?.annotations?.var?.index;
if (!varIndex) return null; if (!varIndex) return null;
if (isGeneSetSummary) {
return [
"X",
{
summarize: {
method: "mean",
field: "var",
column: varIndex,
values: setGenes,
},
},
];
}
// else, we assume it is a gene expression
return [ return [
"X", "X",
{ {
field: "var", where: {
column: varIndex, field: "var",
value: field, column: varIndex,
value: field,
},
}, },
]; ];
} }
@@ -333,6 +371,7 @@ class HistogramBrush extends React.PureComponent {
continuousSelectionRange, continuousSelectionRange,
isObs, isObs,
mini, mini,
setGenes,
} = this.props; } = this.props;
const { const {
margin, margin,
@@ -346,7 +385,11 @@ class HistogramBrush extends React.PureComponent {
const showScatterPlot = isDiffExp || isUserDefined; const showScatterPlot = isDiffExp || isUserDefined;
return ( return (
<Async watch={annoMatrix} promiseFn={this.fetchAsyncProps}> <Async
watchFn={HistogramBrush.watchAsync}
promiseFn={this.fetchAsyncProps}
watchProps={{ annoMatrix, setGenes }}
>
<Async.Pending initial> <Async.Pending initial>
<StillLoading displayName={field} zebra={zebra} /> <StillLoading displayName={field} zebra={zebra} />
</Async.Pending> </Async.Pending>
+1 -6
View File
@@ -53,12 +53,7 @@ class Gene extends React.Component {
handleDeleteGeneFromSet = () => { handleDeleteGeneFromSet = () => {
const { dispatch, gene, geneset } = this.props; const { dispatch, gene, geneset } = this.props;
dispatch(actions.genesetDeleteGenes(geneset, [gene]));
dispatch({
type: "geneset: delete genes",
genesetName: geneset,
geneSymbols: [gene],
});
}; };
render() { render() {
+19 -11
View File
@@ -9,6 +9,7 @@ import { memoize } from "../../util/dataframe/util";
import Truncate from "../util/truncate"; import Truncate from "../util/truncate";
import * as globals from "../../globals"; import * as globals from "../../globals";
import GenesetMenus from "./menus/genesetMenus"; import GenesetMenus from "./menus/genesetMenus";
import HistogramBrush from "../brushableHistogram";
@connect((state, ownProps) => { @connect((state, ownProps) => {
return { return {
@@ -163,18 +164,25 @@ class GeneSet extends React.Component {
) : null} ) : null}
</div> </div>
{isOpen && !toggleSummaryHisto {isOpen &&
? _.map(setGenes, (gene) => { (!toggleSummaryHisto
return ( ? _.map(setGenes, (gene) => {
<Gene return (
key={gene} <Gene
gene={gene} key={gene}
geneset={setName} gene={gene}
isDiffexp={isDiffexp} geneset={setName}
isDiffexp={isDiffexp}
/>
);
})
: setGenes.length > 0 && (
<HistogramBrush
isGeneSetSummary
field={setName}
setGenes={setGenes}
/> />
); ))}
})
: null}
</div> </div>
); );
} }
@@ -3,6 +3,7 @@ import { connect } from "react-redux";
import AnnoDialog from "../../annoDialog"; import AnnoDialog from "../../annoDialog";
import LabelInput from "../../labelInput"; import LabelInput from "../../labelInput";
import parseBulkGeneString from "../../../util/parseBulkGeneString"; import parseBulkGeneString from "../../../util/parseBulkGeneString";
import actions from "../../../actions";
@connect((state) => ({ @connect((state) => ({
genesetsUI: state.genesetsUI, genesetsUI: state.genesetsUI,
@@ -36,11 +37,7 @@ class AddGeneToGenesetDialogue extends React.PureComponent {
}); });
}); });
dispatch({ dispatch(actions.genesetAddGenes(geneset, genesTmpHardcodedFormat));
type: "geneset: add genes",
genesetName: geneset,
genes: genesTmpHardcodedFormat,
});
dispatch({ dispatch({
type: "geneset: disable add new genes mode", type: "geneset: disable add new genes mode",
}); });
@@ -77,6 +77,26 @@ class AddGenes extends React.Component {
this.updateState(prevProps); this.updateState(prevProps);
} }
handleClick(g) {
const { dispatch, userDefinedGenes } = this.props;
const { geneNames } = this.state;
if (!g) return;
const gene = g.target;
if (userDefinedGenes.indexOf(gene) !== -1) {
postUserErrorToast("That gene already exists");
} else if (userDefinedGenes.length > globals.maxUserDefinedGenes) {
postUserErrorToast(
`That's too many genes, you can have at most ${globals.maxUserDefinedGenes} user defined genes`
);
} else if (geneNames.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));
dispatch({ type: "single user defined gene complete" });
}
}
_genesToUpper = (listGenes) => { _genesToUpper = (listGenes) => {
// Has to be a Map to preserve index // Has to be a Map to preserve index
const upperGenes = new Map(); const upperGenes = new Map();
@@ -190,26 +210,6 @@ class AddGenes extends React.Component {
return "Apod, Cd74, ..."; return "Apod, Cd74, ...";
} }
handleClick(g) {
const { dispatch, userDefinedGenes } = this.props;
const { geneNames } = this.state;
if (!g) return;
const gene = g.target;
if (userDefinedGenes.indexOf(gene) !== -1) {
postUserErrorToast("That gene already exists");
} else if (userDefinedGenes.length > globals.maxUserDefinedGenes) {
postUserErrorToast(
`That's too many genes, you can have at most ${globals.maxUserDefinedGenes} user defined genes`
);
} else if (geneNames.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));
dispatch({ type: "single user defined gene complete" });
}
}
render() { render() {
const { userDefinedGenesLoading } = this.props; const { userDefinedGenesLoading } = this.props;
const { tab, bulkAdd, activeItem, status, geneNames } = this.state; const { tab, bulkAdd, activeItem, status, geneNames } = this.state;
@@ -3,6 +3,7 @@ import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import AnnoDialog from "../../annoDialog"; import AnnoDialog from "../../annoDialog";
import LabelInput from "../../labelInput"; import LabelInput from "../../labelInput";
import actions from "../../../actions";
@connect((state) => ({ @connect((state) => ({
annotations: state.annotations, annotations: state.annotations,
@@ -56,11 +57,7 @@ class CreateGenesetDialogue extends React.PureComponent {
}); });
}); });
dispatch({ dispatch(actions.genesetAddGenes(genesetName, genesTmpHardcodedFormat));
type: "geneset: add genes",
genesetName,
genes: genesTmpHardcodedFormat,
});
} }
dispatch({ dispatch({
type: "geneset: disable create geneset mode", type: "geneset: disable create geneset mode",
@@ -12,6 +12,7 @@ import {
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import * as globals from "../../../globals"; import * as globals from "../../../globals";
import actions from "../../../actions";
import AddGeneToGenesetDialogue from "./addGeneToGenesetDialogue"; import AddGeneToGenesetDialogue from "./addGeneToGenesetDialogue";
@connect((state) => { @connect((state) => {
@@ -44,7 +45,7 @@ class GenesetMenus extends React.PureComponent {
handleDeleteCategory = () => { handleDeleteCategory = () => {
const { dispatch, geneset } = this.props; const { dispatch, geneset } = this.props;
dispatch({ type: "geneset: delete", genesetName: geneset }); dispatch(actions.genesetDelete(geneset));
}; };
render() { render() {
+137 -137
View File
@@ -303,13 +303,6 @@ class Graph extends React.Component {
window.removeEventListener("resize", this.handleResize); window.removeEventListener("resize", this.handleResize);
} }
setReglCanvas = (canvas) => {
this.reglCanvas = canvas;
this.setState({
...Graph.createReglState(canvas),
});
};
handleResize = () => { handleResize = () => {
const { state } = this.state; const { state } = this.state;
const viewport = this.getViewportDimensions(); const viewport = this.getViewportDimensions();
@@ -321,14 +314,6 @@ class Graph extends React.Component {
}); });
}; };
getViewportDimensions = () => {
const { viewportRef } = this.props;
return {
height: viewportRef.clientHeight,
width: viewportRef.clientWidth,
};
};
handleCanvasEvent = (e) => { handleCanvasEvent = (e) => {
const { camera, projectionTF } = this.state; const { camera, projectionTF } = this.state;
if (e.type !== "wheel") e.preventDefault(); if (e.type !== "wheel") e.preventDefault();
@@ -340,6 +325,143 @@ class Graph extends React.Component {
} }
}; };
handleBrushDragAction() {
/*
event describing brush position:
@-------|
| |
| |
|-------@
*/
// ignore programatically generated events
if (d3.event.sourceEvent === null || !d3.event.selection) return;
const { dispatch, layoutChoice } = this.props;
const s = d3.event.selection;
const northwest = this.mapScreenToPoint(s[0]);
const southeast = this.mapScreenToPoint(s[1]);
const [minX, maxY] = northwest;
const [maxX, minY] = southeast;
dispatch(
actions.graphBrushChangeAction(layoutChoice.current, {
minX,
minY,
maxX,
maxY,
northwest,
southeast,
})
);
}
handleBrushStartAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
const { dispatch } = this.props;
dispatch(actions.graphBrushStartAction());
}
handleBrushEndAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
/*
coordinates will be included if selection made, null
if selection cleared.
*/
const { dispatch, layoutChoice } = this.props;
const s = d3.event.selection;
if (s) {
const northwest = this.mapScreenToPoint(s[0]);
const southeast = this.mapScreenToPoint(s[1]);
const [minX, maxY] = northwest;
const [maxX, minY] = southeast;
dispatch(
actions.graphBrushEndAction(layoutChoice.current, {
minX,
minY,
maxX,
maxY,
northwest,
southeast,
})
);
} else {
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
}
}
handleBrushDeselectAction() {
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
}
handleLassoStart() {
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoStartAction(layoutChoice.current));
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const minimumPolygonArea = 10;
const { dispatch, layoutChoice } = 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(actions.graphLassoDeselectAction(layoutChoice.current));
} else {
dispatch(
actions.graphLassoEndAction(
layoutChoice.current,
polygon.map((xy) => this.mapScreenToPoint(xy))
)
);
}
}
handleLassoCancel() {
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoCancelAction(layoutChoice.current));
}
handleLassoDeselectAction() {
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoDeselectAction(layoutChoice.current));
}
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,
});
}
setReglCanvas = (canvas) => {
this.reglCanvas = canvas;
this.setState({
...Graph.createReglState(canvas),
});
};
getViewportDimensions = () => {
const { viewportRef } = this.props;
return {
height: viewportRef.clientHeight,
width: viewportRef.clientWidth,
};
};
createToolSVG = () => { createToolSVG = () => {
/* /*
Called from componentDidUpdate. Create the tool SVG, and return any Called from componentDidUpdate. Create the tool SVG, and return any
@@ -589,128 +711,6 @@ class Graph extends React.Component {
]; ];
} }
handleBrushDragAction() {
/*
event describing brush position:
@-------|
| |
| |
|-------@
*/
// ignore programatically generated events
if (d3.event.sourceEvent === null || !d3.event.selection) return;
const { dispatch, layoutChoice } = this.props;
const s = d3.event.selection;
const northwest = this.mapScreenToPoint(s[0]);
const southeast = this.mapScreenToPoint(s[1]);
const [minX, maxY] = northwest;
const [maxX, minY] = southeast;
dispatch(
actions.graphBrushChangeAction(layoutChoice.current, {
minX,
minY,
maxX,
maxY,
northwest,
southeast,
})
);
}
handleBrushStartAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
const { dispatch } = this.props;
dispatch(actions.graphBrushStartAction());
}
handleBrushEndAction() {
// Ignore programatically generated events.
if (!d3.event.sourceEvent) return;
/*
coordinates will be included if selection made, null
if selection cleared.
*/
const { dispatch, layoutChoice } = this.props;
const s = d3.event.selection;
if (s) {
const northwest = this.mapScreenToPoint(s[0]);
const southeast = this.mapScreenToPoint(s[1]);
const [minX, maxY] = northwest;
const [maxX, minY] = southeast;
dispatch(
actions.graphBrushEndAction(layoutChoice.current, {
minX,
minY,
maxX,
maxY,
northwest,
southeast,
})
);
} else {
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
}
}
handleBrushDeselectAction() {
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
}
handleLassoStart() {
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoStartAction(layoutChoice.current));
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const minimumPolygonArea = 10;
const { dispatch, layoutChoice } = 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(actions.graphLassoDeselectAction(layoutChoice.current));
} else {
dispatch(
actions.graphLassoEndAction(
layoutChoice.current,
polygon.map((xy) => this.mapScreenToPoint(xy))
)
);
}
}
handleLassoCancel() {
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoCancelAction(layoutChoice.current));
}
handleLassoDeselectAction() {
const { dispatch, layoutChoice } = this.props;
dispatch(actions.graphLassoDeselectAction(layoutChoice.current));
}
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,
});
}
renderCanvas = renderThrottle(() => { renderCanvas = renderThrottle(() => {
const { const {
regl, regl,
@@ -11,7 +11,7 @@ import { selectableCategoryNames } from "../../util/stateManager/controlsHelpers
datasetTitle: state.config?.displayNames?.dataset ?? "", datasetTitle: state.config?.displayNames?.dataset ?? "",
aboutURL: state.config?.links?.["about-dataset"], aboutURL: state.config?.links?.["about-dataset"],
isOpen: state.controls.datasetDrawer, isOpen: state.controls.datasetDrawer,
dataPortalProps: state.config?.["corpora_props"], dataPortalProps: state.config?.corpora_props,
}; };
}) })
class InfoDrawer extends PureComponent { class InfoDrawer extends PureComponent {
@@ -169,7 +169,7 @@ const InfoFormat = React.memo(
({ datasetTitle, singleValueCategories, aboutURL, dataPortalProps = {} }) => { ({ datasetTitle, singleValueCategories, aboutURL, dataPortalProps = {} }) => {
if ( if (
["1.0.0", "1.1.0"].indexOf( ["1.0.0", "1.1.0"].indexOf(
dataPortalProps.version?.["corpora_schema_version"] dataPortalProps.version?.corpora_schema_version
) === -1 ) === -1
) { ) {
dataPortalProps = {}; dataPortalProps = {};
@@ -13,15 +13,14 @@ const DATASET_TITLE_FONT_SIZE = 14;
@connect((state) => { @connect((state) => {
const { corpora_props: corporaProps } = state.config; const { corpora_props: corporaProps } = state.config;
const correctVersion = const correctVersion =
["1.0.0", "1.1.0"].indexOf( ["1.0.0", "1.1.0"].indexOf(corporaProps?.version?.corpora_schema_version) >
corporaProps?.version?.["corpora_schema_version"] -1;
) > -1;
return { return {
datasetTitle: state.config?.displayNames?.dataset ?? "", datasetTitle: state.config?.displayNames?.dataset ?? "",
libraryVersions: state.config?.["library_versions"], libraryVersions: state.config?.library_versions,
aboutLink: state.config?.links?.["about-dataset"], aboutLink: state.config?.links?.["about-dataset"],
tosURL: state.config?.parameters?.["about_legal_tos"], tosURL: state.config?.parameters?.about_legal_tos,
privacyURL: state.config?.parameters?.["about_legal_privacy"], privacyURL: state.config?.parameters?.about_legal_privacy,
title: correctVersion ? corporaProps?.title : undefined, title: correctVersion ? corporaProps?.title : undefined,
}; };
}) })
@@ -11,7 +11,7 @@ import CellSetButton from "./cellSetButtons";
celllist1: state.differential?.celllist1, celllist1: state.differential?.celllist1,
celllist2: state.differential?.celllist2, celllist2: state.differential?.celllist2,
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false, diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
diffexpCellcountMax: state.config?.limits?.["diffexp_cellcount_max"], diffexpCellcountMax: state.config?.limits?.diffexp_cellcount_max,
})) }))
class DiffexpButtons extends React.PureComponent { class DiffexpButtons extends React.PureComponent {
computeDiffExp = () => { computeDiffExp = () => {
+3 -3
View File
@@ -40,7 +40,7 @@ import { getEmbSubsetView } from "../../util/stateManager/viewStackHelpers";
scatterplotYYaccessor: state.controls.scatterplotYYaccessor, scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
celllist1: state.differential.celllist1, celllist1: state.differential.celllist1,
celllist2: state.differential.celllist2, celllist2: state.differential.celllist2,
libraryVersions: state.config?.["library_versions"], libraryVersions: state.config?.library_versions,
auth: state.config?.authentication, auth: state.config?.authentication,
userInfo: state.userInfo, userInfo: state.userInfo,
undoDisabled: state["@@undoable/past"].length === 0, undoDisabled: state["@@undoable/past"].length === 0,
@@ -50,8 +50,8 @@ import { getEmbSubsetView } from "../../util/stateManager/viewStackHelpers";
diffexpMayBeSlow: diffexpMayBeSlow:
state.config?.parameters?.["diffexp-may-be-slow"] ?? false, state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
showCentroidLabels: state.centroidLabels.showLabels, showCentroidLabels: state.centroidLabels.showLabels,
tosURL: state.config?.parameters?.["about_legal_tos"], tosURL: state.config?.parameters?.about_legal_tos,
privacyURL: state.config?.parameters?.["about_legal_privacy"], privacyURL: state.config?.parameters?.about_legal_privacy,
categoricalSelection: state.categoricalSelection, categoricalSelection: state.categoricalSelection,
enableReembedding: enableReembedding:
state.config?.parameters?.["enable-reembedding"] ?? false, state.config?.parameters?.["enable-reembedding"] ?? false,
@@ -303,9 +303,11 @@ class Scatterplot extends React.PureComponent {
return [ return [
"X", "X",
{ {
field: "var", where: {
column: varIndex, field: "var",
value: geneName, column: varIndex,
value: geneName,
},
}, },
]; ];
} }
+2 -2
View File
@@ -11,8 +11,8 @@ import {
import { storageGet, storageSet, KEYS } from "../util/localStorage"; import { storageGet, storageSet, KEYS } from "../util/localStorage";
@connect((state) => ({ @connect((state) => ({
tosURL: state.config?.parameters?.["about_legal_tos"], tosURL: state.config?.parameters?.about_legal_tos,
privacyURL: state.config?.parameters?.["about_legal_privacy"], privacyURL: state.config?.parameters?.about_legal_privacy,
})) }))
class TermsPrompt extends React.PureComponent { class TermsPrompt extends React.PureComponent {
constructor(props) { constructor(props) {
+1 -1
View File
@@ -39,7 +39,7 @@ const Annotations = (
"annotations-data-collection-name-is-read-only" "annotations-data-collection-name-is-read-only"
] ?? false; ] ?? false;
const promptForFilename = const promptForFilename =
action.config.parameters?.["user_annotation_collection_name_enabled"]; action.config.parameters?.user_annotation_collection_name_enabled;
return { return {
...state, ...state,
dataCollectionNameIsReadOnly, dataCollectionNameIsReadOnly,
+1 -1
View File
@@ -76,7 +76,7 @@ const Autosave = (
const { lastSavedGenesets } = action; const { lastSavedGenesets } = action;
return { return {
...state, ...state,
genesetSaveInProgess: false, genesetSaveInProgress: false,
error: false, error: false,
lastSavedGenesets, lastSavedGenesets,
}; };
+2 -2
View File
@@ -57,12 +57,12 @@ const GeneSets = (
for (const gene of gsData.genes) { for (const gene of gsData.genes) {
genes.set(gene.gene_symbol, { genes.set(gene.gene_symbol, {
geneSymbol: gene.gene_symbol, geneSymbol: gene.gene_symbol,
geneDescription: gene?.["gene_description"] ?? "", geneDescription: gene?.gene_description ?? "",
}); });
} }
const gs = { const gs = {
genesetName: gsData.geneset_name, genesetName: gsData.geneset_name,
genesetDescription: gsData?.["geneset_description"] ?? "", genesetDescription: gsData?.geneset_description ?? "",
genes, genes,
}; };
genesets.set(gsData.geneset_name, gs); genesets.set(gsData.geneset_name, gs);
+2 -4
View File
@@ -10,10 +10,8 @@ const Ontology = (
switch (action.type) { switch (action.type) {
case "configuration load complete": { case "configuration load complete": {
const enabled = const enabled =
action.config?.parameters?.["annotations_cell_ontology_enabled"] ?? action.config?.parameters?.annotations_cell_ontology_enabled ?? false;
false; const terms = action.config?.parameters?.annotations_cell_ontology_terms;
const terms =
action.config?.parameters?.["annotations_cell_ontology_terms"];
const termSet = new Set(terms); const termSet = new Set(terms);
return { return {
+1 -1
View File
@@ -178,7 +178,7 @@ See undoable.js for description action filter interface description.
Basic approach: Basic approach:
* trivial handlers for skip, clear & save cases to keep config simple. * trivial handlers for skip, clear & save cases to keep config simple.
* only implement complex state machines where absolutely required (eg, * only implement complex state machines where absolutely required (eg,
multi-event seleciton and the like) multi-event selection and the like)
*/ */
const actionFilter = (debug) => (state, action, prevFilterState) => { const actionFilter = (debug) => (state, action, prevFilterState) => {
const actionType = action.type; const actionType = action.type;
+6
View File
@@ -100,6 +100,12 @@ const createFsmTransitions = (
to: "done", to: "done",
action: cancelPending, action: cancelPending,
}, },
{
event: "continuous metadata histogram cancel",
from: "init",
to: "done",
action: save,
},
{ {
event: "continuous metadata histogram end", event: "continuous metadata histogram end",
from: "continuous histo select in progress", from: "continuous histo select in progress",
+26 -15
View File
@@ -30,22 +30,27 @@ export function catchErrorsWrap(fn, dispatchToUser = false) {
}; };
} }
/* /**
Wrapper to perform async fetch with some modest error handling * Wrapper to perform async fetch with some modest error handling
and decoding. * and decoding. Arguments are identical to standard fetch.
*/ */
const doFetch = async (url, acceptType) => { export const doFetch = async (url, init = {}) => {
try { try {
const res = await fetch(url, { // add defaults to the fetch init param.
init = {
method: "get", method: "get",
headers: new Headers({
Accept: acceptType,
}),
credentials: "include", credentials: "include",
}); ...init,
if (res.ok && res.headers.get("Content-Type").includes(acceptType)) { };
const acceptType = init.headers?.get("Accept");
const res = await fetch(url, init);
if (
res.ok &&
(!acceptType || res.headers.get("Content-Type").includes(acceptType))
) {
return res; return res;
} }
// else an error // else an error
const msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`; const msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
dispatchNetworkErrorMessageToUser(msg); dispatchNetworkErrorMessageToUser(msg);
@@ -61,16 +66,22 @@ const doFetch = async (url, acceptType) => {
/* /*
Wrapper to perform an async fetch and JSON decode response. Wrapper to perform an async fetch and JSON decode response.
*/ */
export const doJsonRequest = async (url) => { export const doJsonRequest = async (url, init = {}) => {
const res = await doFetch(url, "application/json"); const res = await doFetch(url, {
...init,
headers: new Headers({ Accept: "application/json" }),
});
return res.json(); return res.json();
}; };
/* /*
Wrapper to perform an async fetch for binary data. Wrapper to perform an async fetch for binary data.
*/ */
export const doBinaryRequest = async (url) => { export const doBinaryRequest = async (url, init = {}) => {
const res = await doFetch(url, "application/octet-stream"); const res = await doFetch(url, {
...init,
headers: new Headers({ Accept: "application/octet-stream" }),
});
return res.arrayBuffer(); return res.arrayBuffer();
}; };
+9 -1
View File
@@ -8,6 +8,9 @@ have a obsAnnotation named X, but we are using that for layout. So
we namespace, and abstract to avoid proliferating strings throughout the we namespace, and abstract to avoid proliferating strings throughout the
codebase. codebase.
It is _no longer_ used to remove collisions in the crossfilter or
anno matrix namespaces. It is still used by the component tier.
*/ */
const makeDimensionName = (namespace, key) => `${namespace}_${key}`; const makeDimensionName = (namespace, key) => `${namespace}_${key}`;
@@ -18,12 +21,15 @@ export const diffexpDimensionName = (key) =>
makeDimensionName("varData_diffexp", key); makeDimensionName("varData_diffexp", key);
export const userDefinedDimensionName = (key) => export const userDefinedDimensionName = (key) =>
makeDimensionName("varData_userDefined", key); makeDimensionName("varData_userDefined", key);
export const geneSetSummaryDimensionName = (key) =>
makeDimensionName("geneSetSummary", key);
/* /*
continuousNamespace = { continuousNamespace = {
isObs: true, isObs: true,
isDiffExp: false, isDiffExp: false,
isUserDefined: false isUserDefined: false,
isGeneSet: false,
} }
ie., makeContinuousDimensionName(continuousNamespace = {isObs: true}, "total_reads") ie., makeContinuousDimensionName(continuousNamespace = {isObs: true}, "total_reads")
@@ -37,6 +43,8 @@ export const makeContinuousDimensionName = (continuousNamespace, key) => {
name = diffexpDimensionName(key); name = diffexpDimensionName(key);
} else if (continuousNamespace.isUserDefined) { } else if (continuousNamespace.isUserDefined) {
name = userDefinedDimensionName(key); name = userDefinedDimensionName(key);
} else if (continuousNamespace.isGeneSetSummary) {
name = geneSetSummaryDimensionName(key);
} else { } else {
throw new Error("unknown continuous dimension"); throw new Error("unknown continuous dimension");
} }
+5 -3
View File
@@ -25,9 +25,11 @@ export function createColorQuery(colorMode, colorByAccessor, schema) {
return [ return [
"X", "X",
{ {
field: "var", where: {
column: varIndex, field: "var",
value: colorByAccessor, column: varIndex,
value: colorByAccessor,
},
}, },
]; ];
} }
+13 -5
View File
@@ -4,8 +4,16 @@ This is all VERY tightly integrated with reducers and actions, and
exists to support those concepts. exists to support those concepts.
*/ */
export * as ColorHelpers from "./colorHelpers"; import * as ColorHelpers from "./colorHelpers";
export * as ControlsHelpers from "./controlsHelpers"; import * as ControlsHelpers from "./controlsHelpers";
export * as AnnotationsHelpers from "./annotationsHelpers"; import * as AnnotationsHelpers from "./annotationsHelpers";
export * as SchemaHelpers from "./schemaHelpers"; import * as SchemaHelpers from "./schemaHelpers";
export * as MatrixFBS from "./matrix"; import * as MatrixFBS from "./matrix";
export {
ColorHelpers,
ControlsHelpers,
AnnotationsHelpers,
SchemaHelpers,
MatrixFBS,
};