mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-01 00:58:11 +08:00
load annotations incrementally (#1107)
* load annotations individually * fix type check to be more general * update node CI version from 10 to 12 * node 11 * debug print node version * travis node version to latest * try nvm * remove extraneous node_js statement * remove node version debugging printf * incrementally load all annotations and layout * process annotations and layout as they are loaded * fix tests * sort categories incrementally * incrementally build category view summary; add category loading spinner * add spinner to continuous metadata * configure undoable reducer * incremental crossfilter creation * improve busy layout * more layout cleanup * correctly reconcile categories in schema * refine layout of lsb spinners * more spinner layout work * more spinner layout * always load layout before obs annotations
This commit is contained in:
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
language: python
|
language: python
|
||||||
dist: xenial
|
dist: xenial
|
||||||
sudo: required
|
sudo: required
|
||||||
node_js:
|
before_install:
|
||||||
- 10
|
- nvm install node
|
||||||
cache:
|
cache:
|
||||||
- pip
|
- pip
|
||||||
- npm
|
- npm
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ describe("dataframe factories", () => {
|
|||||||
|
|
||||||
test("simple", () => {
|
test("simple", () => {
|
||||||
/* simple test that it works as expected in common case */
|
/* simple test that it works as expected in common case */
|
||||||
|
const dfEmpty = Dataframe.Dataframe.empty();
|
||||||
const dfA = new Dataframe.Dataframe(
|
const dfA = new Dataframe.Dataframe(
|
||||||
[2, 1],
|
[2, 1],
|
||||||
[["red", "blue"]],
|
[["red", "blue"]],
|
||||||
@@ -494,6 +495,22 @@ describe("dataframe factories", () => {
|
|||||||
new Dataframe.KeyIndex(["bools"])
|
new Dataframe.KeyIndex(["bools"])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dfLikeA = dfEmpty.withColsFrom(dfA);
|
||||||
|
expect(dfLikeA).toBeDefined();
|
||||||
|
expect(dfLikeA.dims).toEqual(dfA.dims);
|
||||||
|
expect(dfLikeA.colIndex.keys()).toEqual(dfA.colIndex.keys());
|
||||||
|
expect(dfLikeA.rowIndex).toEqual(dfA.rowIndex);
|
||||||
|
expect(dfLikeA.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
expect(dfLikeA.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||||
|
|
||||||
|
const dfAlsoLikeA = dfA.withColsFrom(dfEmpty);
|
||||||
|
expect(dfAlsoLikeA).toBeDefined();
|
||||||
|
expect(dfAlsoLikeA.dims).toEqual(dfA.dims);
|
||||||
|
expect(dfAlsoLikeA.colIndex.keys()).toEqual(dfA.colIndex.keys());
|
||||||
|
expect(dfAlsoLikeA.rowIndex).toEqual(dfA.rowIndex);
|
||||||
|
expect(dfAlsoLikeA.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
|
||||||
|
expect(dfAlsoLikeA.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||||
|
|
||||||
const dfC = dfA.withColsFrom(dfB);
|
const dfC = dfA.withColsFrom(dfB);
|
||||||
expect(dfC).toBeDefined();
|
expect(dfC).toBeDefined();
|
||||||
expect(dfC.dims).toEqual([2, 2]);
|
expect(dfC.dims).toEqual([2, 2]);
|
||||||
|
|||||||
@@ -30,15 +30,39 @@ describe("createUniverseFromResponse", () => {
|
|||||||
create a universe from sample data nad validate its shape & contents
|
create a universe from sample data nad validate its shape & contents
|
||||||
*/
|
*/
|
||||||
const { nObs, nVar } = REST.schema.schema.dataframe;
|
const { nObs, nVar } = REST.schema.schema.dataframe;
|
||||||
const universe = Universe.createUniverseFromResponse(
|
let universe = Universe.createUniverseFromResponse(
|
||||||
REST.config,
|
REST.config,
|
||||||
REST.schema,
|
REST.schema
|
||||||
REST.annotationsObs,
|
);
|
||||||
REST.annotationsVar,
|
expect(universe).toBeDefined();
|
||||||
REST.layoutObs
|
expect(universe).toMatchObject(
|
||||||
|
expect.objectContaining({
|
||||||
|
nObs,
|
||||||
|
nVar,
|
||||||
|
schema: REST.schema.schema,
|
||||||
|
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||||
|
obsLayout: expect.any(Dataframe.Dataframe),
|
||||||
|
varData: expect.any(Dataframe.Dataframe)
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(universe).toBeDefined();
|
universe = {
|
||||||
|
...universe,
|
||||||
|
...Universe.addObsAnnotations(
|
||||||
|
universe,
|
||||||
|
Universe.matrixFBSToDataframe(REST.annotationsObs)
|
||||||
|
),
|
||||||
|
...Universe.addVarAnnotations(
|
||||||
|
universe,
|
||||||
|
Universe.matrixFBSToDataframe(REST.annotationsVar)
|
||||||
|
),
|
||||||
|
...Universe.addObsLayout(
|
||||||
|
universe,
|
||||||
|
Universe.matrixFBSToDataframe(REST.layoutObs)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
expect(universe).toMatchObject(
|
expect(universe).toMatchObject(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
nObs,
|
nObs,
|
||||||
|
|||||||
@@ -17,13 +17,27 @@ the default REST test response.
|
|||||||
const defaultBigBang = () => {
|
const defaultBigBang = () => {
|
||||||
/* create unverse, world, crossfilter and dimensionMap */
|
/* create unverse, world, crossfilter and dimensionMap */
|
||||||
/* create universe */
|
/* create universe */
|
||||||
const universe = Universe.createUniverseFromResponse(
|
let universe = Universe.createUniverseFromResponse(
|
||||||
_.cloneDeep(REST.config),
|
_.cloneDeep(REST.config),
|
||||||
_.cloneDeep(REST.schema),
|
_.cloneDeep(REST.schema)
|
||||||
_.cloneDeep(REST.annotationsObs),
|
|
||||||
_.cloneDeep(REST.annotationsVar),
|
|
||||||
_.cloneDeep(REST.layoutObs)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
universe = {
|
||||||
|
...universe,
|
||||||
|
...Universe.addObsAnnotations(
|
||||||
|
universe,
|
||||||
|
Universe.matrixFBSToDataframe(REST.annotationsObs)
|
||||||
|
),
|
||||||
|
...Universe.addVarAnnotations(
|
||||||
|
universe,
|
||||||
|
Universe.matrixFBSToDataframe(REST.annotationsVar)
|
||||||
|
),
|
||||||
|
...Universe.addObsLayout(
|
||||||
|
universe,
|
||||||
|
Universe.matrixFBSToDataframe(REST.layoutObs)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
/* create world */
|
/* create world */
|
||||||
const world = World.createWorldFromEntireUniverse(universe);
|
const world = World.createWorldFromEntireUniverse(universe);
|
||||||
/* create crossfilter */
|
/* create crossfilter */
|
||||||
@@ -45,9 +59,9 @@ describe("createWorldFromEntireUniverse", () => {
|
|||||||
const universe = Universe.createUniverseFromResponse(
|
const universe = Universe.createUniverseFromResponse(
|
||||||
_.cloneDeep(REST.config),
|
_.cloneDeep(REST.config),
|
||||||
_.cloneDeep(REST.schema),
|
_.cloneDeep(REST.schema),
|
||||||
_.cloneDeep(REST.annotationsObs),
|
Universe.matrixFBSToDataframe(_.cloneDeep(REST.annotationsObs)),
|
||||||
_.cloneDeep(REST.annotationsVar),
|
Universe.matrixFBSToDataframe(_.cloneDeep(REST.annotationsVar)),
|
||||||
_.cloneDeep(REST.layoutObs)
|
Universe.matrixFBSToDataframe(_.cloneDeep(REST.layoutObs))
|
||||||
);
|
);
|
||||||
expect(universe).toBeDefined();
|
expect(universe).toBeDefined();
|
||||||
|
|
||||||
|
|||||||
+102
-25
@@ -1,4 +1,3 @@
|
|||||||
// jshint esversion: 6
|
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import * as globals from "../globals";
|
import * as globals from "../globals";
|
||||||
import { Universe, MatrixFBS } from "../util/stateManager";
|
import { Universe, MatrixFBS } from "../util/stateManager";
|
||||||
@@ -9,6 +8,89 @@ import {
|
|||||||
dispatchNetworkErrorMessageToUser
|
dispatchNetworkErrorMessageToUser
|
||||||
} from "../util/actionHelpers";
|
} from "../util/actionHelpers";
|
||||||
|
|
||||||
|
/*
|
||||||
|
return promise to fetch the OBS annotations we need to load. Omit anything
|
||||||
|
we don't need.
|
||||||
|
*/
|
||||||
|
function obsAnnotationFetchAndLoad(dispatch, schema, universe) {
|
||||||
|
const obsAnnotations = schema?.schema?.annotations?.obs ?? {};
|
||||||
|
const columns = obsAnnotations.columns ?? [];
|
||||||
|
const index = obsAnnotations.index ?? false;
|
||||||
|
return Promise.all(
|
||||||
|
columns
|
||||||
|
.filter(col => col.name !== index)
|
||||||
|
.map(col => {
|
||||||
|
const path = `annotations/obs?annotation-name=${encodeURIComponent(
|
||||||
|
col.name
|
||||||
|
)}`;
|
||||||
|
const url = `${globals.API.prefix}${globals.API.version}${path}`;
|
||||||
|
return doBinaryRequest(url);
|
||||||
|
})
|
||||||
|
.map(rqst => rqst.then(buffer => Universe.matrixFBSToDataframe(buffer)))
|
||||||
|
.map(resp =>
|
||||||
|
resp.then(df =>
|
||||||
|
dispatch({
|
||||||
|
type: "universe: column load success",
|
||||||
|
dim: "obsAnnotations",
|
||||||
|
dataframe: df
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
return promise fetching VAR annotations we need to load. Only index is currently used.
|
||||||
|
*/
|
||||||
|
function varAnnotationFetchAndLoad(dispatch, schema, universe) {
|
||||||
|
const varAnnotations = schema?.schema?.annotations?.var ?? {};
|
||||||
|
const index = varAnnotations.index ?? false;
|
||||||
|
const names = index ? [index] : [];
|
||||||
|
return Promise.all(
|
||||||
|
names
|
||||||
|
.map(name => {
|
||||||
|
const path = `annotations/var?annotation-name=${encodeURIComponent(
|
||||||
|
name
|
||||||
|
)}`;
|
||||||
|
const url = `${globals.API.prefix}${globals.API.version}${path}`;
|
||||||
|
return doBinaryRequest(url);
|
||||||
|
})
|
||||||
|
.map(rqst => rqst.then(buffer => Universe.matrixFBSToDataframe(buffer)))
|
||||||
|
.map(resp =>
|
||||||
|
resp.then(df =>
|
||||||
|
dispatch({
|
||||||
|
type: "universe: column load success",
|
||||||
|
dim: "varAnnotations",
|
||||||
|
dataframe: df
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
return promise fetching layout we need
|
||||||
|
*/
|
||||||
|
function layoutFetchAndLoad(dispatch, schema, universe) {
|
||||||
|
return Promise.all(
|
||||||
|
["layout/obs"]
|
||||||
|
.map(path => {
|
||||||
|
const url = `${globals.API.prefix}${globals.API.version}${path}`;
|
||||||
|
return doBinaryRequest(url);
|
||||||
|
})
|
||||||
|
.map(rqst => rqst.then(buffer => Universe.matrixFBSToDataframe(buffer)))
|
||||||
|
.map(resp =>
|
||||||
|
resp.then(df =>
|
||||||
|
dispatch({
|
||||||
|
type: "universe: column load success",
|
||||||
|
dim: "obsLayout",
|
||||||
|
dataframe: df
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Bootstrap application with the initial data loading.
|
Bootstrap application with the initial data loading.
|
||||||
* /config - application configuration
|
* /config - application configuration
|
||||||
@@ -31,34 +113,29 @@ const doInitialDataLoad = () =>
|
|||||||
/* set config defaults */
|
/* set config defaults */
|
||||||
const config = { ...globals.configDefaults, ...stepOneResults[0].config };
|
const config = { ...globals.configDefaults, ...stepOneResults[0].config };
|
||||||
const schema = stepOneResults[1];
|
const schema = stepOneResults[1];
|
||||||
|
const universe = Universe.createUniverseFromResponse(config, schema);
|
||||||
/*
|
dispatch({
|
||||||
Step 2 - dataframes, all binary. NOTE: uses results of step 1.
|
type: "universe exists, but loading is still in progress",
|
||||||
*/
|
universe
|
||||||
/* only load names for var annotations, if possible*/
|
});
|
||||||
const varIndexName = schema?.schema?.annotations?.var?.index;
|
|
||||||
const varAnnotationsQuery = varIndexName
|
|
||||||
? `?annotation-name=${encodeURIComponent(varIndexName)}`
|
|
||||||
: "";
|
|
||||||
const varAnnotationsURL = `annotations/var${varAnnotationsQuery}`;
|
|
||||||
const requestBinary = ["annotations/obs", varAnnotationsURL, "layout/obs"]
|
|
||||||
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
|
|
||||||
.map(url => doBinaryRequest(url));
|
|
||||||
const stepTwoResults = await Promise.all(requestBinary);
|
|
||||||
const [obsAnno, varAnno, obsLayout] = [...stepTwoResults];
|
|
||||||
|
|
||||||
const universe = Universe.createUniverseFromResponse(
|
|
||||||
config,
|
|
||||||
schema,
|
|
||||||
obsAnno,
|
|
||||||
varAnno,
|
|
||||||
obsLayout
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "configuration load complete",
|
type: "configuration load complete",
|
||||||
config
|
config
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Step 2 - load the minimum stuff required to display.
|
||||||
|
*/
|
||||||
|
await Promise.all([
|
||||||
|
layoutFetchAndLoad(dispatch, schema, universe),
|
||||||
|
varAnnotationFetchAndLoad(dispatch, schema, universe)
|
||||||
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Step 3 - load everything else
|
||||||
|
*/
|
||||||
|
await obsAnnotationFetchAndLoad(dispatch, schema, universe);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "initial data load complete (universe exists)",
|
type: "initial data load complete (universe exists)",
|
||||||
universe
|
universe
|
||||||
|
|||||||
@@ -351,8 +351,8 @@ class HistogramBrush extends React.PureComponent {
|
|||||||
const brushX = d3
|
const brushX = d3
|
||||||
.brushX()
|
.brushX()
|
||||||
.extent([
|
.extent([
|
||||||
[x.range()[0], y.range()[1]],
|
[x.range()[0], y.range()[1]],
|
||||||
[x.range()[1], this.marginTop + this.height + this.marginBottom]
|
[x.range()[1], this.marginTop + this.height + this.marginBottom]
|
||||||
])
|
])
|
||||||
/*
|
/*
|
||||||
emit start so that the Undoable history can save an undo point
|
emit start so that the Undoable history can save an undo point
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import { Button } from "@blueprintjs/core";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import Category from "./category";
|
import Category from "./category";
|
||||||
import { AnnotationsHelpers } from "../../util/stateManager";
|
import { AnnotationsHelpers, ControlsHelpers } from "../../util/stateManager";
|
||||||
import AnnoDialog from "./annoDialog";
|
import AnnoDialog from "./annoDialog";
|
||||||
import AnnoInputs from "./annoInputs";
|
import AnnoInputs from "./annoInputs";
|
||||||
import AnnoSelect from "./annoSelect";
|
import AnnoSelect from "./annoSelect";
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
categoricalSelection: state.categoricalSelection,
|
|
||||||
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false,
|
writableCategoriesEnabled: state.config?.parameters?.["annotations"] ?? false,
|
||||||
schema: state.world?.schema
|
schema: state.world?.schema,
|
||||||
|
config: state.config
|
||||||
}))
|
}))
|
||||||
class Categories extends React.Component {
|
class Categories extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -123,12 +123,15 @@ class Categories extends React.Component {
|
|||||||
const {
|
const {
|
||||||
categoricalSelection,
|
categoricalSelection,
|
||||||
writableCategoriesEnabled,
|
writableCategoriesEnabled,
|
||||||
schema
|
schema,
|
||||||
|
config
|
||||||
} = this.props;
|
} = this.props;
|
||||||
if (!categoricalSelection) return null;
|
|
||||||
|
|
||||||
/* all names, sorted in display order. Will be rendered in this order */
|
/* all names, sorted in display order. Will be rendered in this order */
|
||||||
const allCategoryNames = Object.keys(categoricalSelection).sort();
|
const allCategoryNames = ControlsHelpers.selectableCategoryNames(
|
||||||
|
schema,
|
||||||
|
ControlsHelpers.maxCategoryItems(config)
|
||||||
|
).sort();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||||
import { Button, Tooltip, Icon } from "@blueprintjs/core";
|
import { Button, Tooltip, Icon, Spinner } from "@blueprintjs/core";
|
||||||
import CategoryFlipperLayout from "./categoryFlipperLayout";
|
import CategoryFlipperLayout from "./categoryFlipperLayout";
|
||||||
import AnnoMenu from "./annoMenuCategory";
|
import AnnoMenu from "./annoMenuCategory";
|
||||||
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
|
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
|
||||||
@@ -31,8 +31,12 @@ class Category extends React.Component {
|
|||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { categoricalSelection, metadataField } = this.props;
|
const { categoricalSelection, metadataField } = this.props;
|
||||||
if (categoricalSelection !== prevProps.categoricalSelection) {
|
const cat = categoricalSelection?.[metadataField];
|
||||||
const cat = categoricalSelection[metadataField];
|
if (
|
||||||
|
categoricalSelection !== prevProps.categoricalSelection &&
|
||||||
|
!!cat &&
|
||||||
|
!!this.checkbox
|
||||||
|
) {
|
||||||
const categoryCount = {
|
const categoryCount = {
|
||||||
// total number of categories in this dimension
|
// total number of categories in this dimension
|
||||||
totalCatCount: cat.numCategoryValues,
|
totalCatCount: cat.numCategoryValues,
|
||||||
@@ -95,15 +99,66 @@ class Category extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderIsStillLoading(metadataField) {
|
||||||
|
/*
|
||||||
|
We are still loading this category, so render a "busy" signal.
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: globals.maxControlsWidth
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "flex-start"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="bp3-control bp3-checkbox">
|
||||||
|
<input disabled checked={true} type="checkbox" />
|
||||||
|
<span className="bp3-control-indicator" />
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-block"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metadataField}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button minimal loading intent="primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isExpanded, isChecked } = this.state;
|
const { isExpanded, isChecked } = this.state;
|
||||||
const {
|
const {
|
||||||
metadataField,
|
metadataField,
|
||||||
|
categoricalSelection,
|
||||||
colorAccessor,
|
colorAccessor,
|
||||||
isUserAnno,
|
isUserAnno,
|
||||||
annotations
|
annotations
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
const isStillLoading = !(categoricalSelection?.[metadataField] ?? false);
|
||||||
|
if (isStillLoading) {
|
||||||
|
return this.renderIsStillLoading(metadataField);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CategoryFlipperLayout
|
<CategoryFlipperLayout
|
||||||
metadataField={metadataField}
|
metadataField={metadataField}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React from "react";
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
|
import { Button } from "@blueprintjs/core";
|
||||||
|
|
||||||
import HistogramBrush from "../brushableHistogram";
|
import HistogramBrush from "../brushableHistogram";
|
||||||
|
|
||||||
@@ -14,17 +15,7 @@ import HistogramBrush from "../brushableHistogram";
|
|||||||
colorScale: state.colors.scale,
|
colorScale: state.colors.scale,
|
||||||
schema: state.world?.schema
|
schema: state.world?.schema
|
||||||
}))
|
}))
|
||||||
class Continuous extends React.Component {
|
class Continuous extends React.PureComponent {
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.hasContinuous = false;
|
|
||||||
this.continuousChecked = false;
|
|
||||||
|
|
||||||
this.state = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {}
|
|
||||||
|
|
||||||
handleColorAction = key => {
|
handleColorAction = key => {
|
||||||
return () => {
|
return () => {
|
||||||
const { dispatch, obsAnnotations } = this.props;
|
const { dispatch, obsAnnotations } = this.props;
|
||||||
@@ -37,61 +28,82 @@ class Continuous extends React.Component {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
renderIsStillLoading(zebra, key) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
padding: globals.leftSidebarSectionPadding,
|
||||||
|
backgroundColor: zebra % 2 === 0 ? globals.lightestGrey : "white"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
justifyItems: "center",
|
||||||
|
alignItems: "center"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 30 }}></div>
|
||||||
|
<div style={{ display: "flex", alignSelf: "center" }}>
|
||||||
|
<span style={{ fontStyle: "italic" }}>{key}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button minimal loading intent="primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { obsAnnotations, schema } = this.props;
|
const { obsAnnotations, schema } = this.props;
|
||||||
if (schema && !this.continuousChecked) {
|
|
||||||
this.hasContinuous = _.some(
|
const obsIndex = schema.annotations.obs.index;
|
||||||
schema.annotations.obs,
|
const allContinuousNames = schema.annotations.obs.columns
|
||||||
d => d.type === "int32" || d.type === "float32"
|
.filter(col => col.type === "int32" || col.type === "float32")
|
||||||
);
|
.filter(col => col.name != obsIndex)
|
||||||
this.continuousChecked = true; /* only do this once */
|
.map(col => col.name);
|
||||||
}
|
|
||||||
|
|
||||||
/* initial value for iterator to simulate index, ranges is an object */
|
/* initial value for iterator to simulate index, ranges is an object */
|
||||||
let zebra = 0;
|
let zebra = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{this.hasContinuous ? (
|
{allContinuousNames.map(key => {
|
||||||
<p
|
if (!obsAnnotations.hasCol(key)) {
|
||||||
style={{
|
// still loading!
|
||||||
...globals.leftSidebarSectionHeading,
|
zebra += 1;
|
||||||
marginTop: 40,
|
return this.renderIsStillLoading(zebra, key);
|
||||||
paddingLeft: globals.leftSidebarSectionPadding
|
} else {
|
||||||
}}
|
// data loaded and available
|
||||||
>
|
const summary = obsAnnotations.col(key).summarize();
|
||||||
Continuous metadata
|
const nonFiniteExtent =
|
||||||
</p>
|
summary.min === undefined ||
|
||||||
) : null}
|
summary.max === undefined ||
|
||||||
{obsAnnotations
|
Number.isNaN(summary.min) ||
|
||||||
? _.map(obsAnnotations.colIndex.keys(), key => {
|
Number.isNaN(summary.max);
|
||||||
const isColorField =
|
if (!summary.categorical && !nonFiniteExtent) {
|
||||||
key.includes("color") || key.includes("Color");
|
zebra += 1;
|
||||||
if (key === schema.annotations.obs.index || isColorField)
|
return (
|
||||||
return null;
|
<HistogramBrush
|
||||||
|
key={key}
|
||||||
const summary = obsAnnotations.col(key).summarize();
|
field={key}
|
||||||
const nonFiniteExtent =
|
isObs
|
||||||
summary.min === undefined ||
|
zebra={zebra % 2 === 0}
|
||||||
summary.max === undefined ||
|
ranges={summary}
|
||||||
Number.isNaN(summary.min) ||
|
handleColorAction={this.handleColorAction(key)}
|
||||||
Number.isNaN(summary.max);
|
/>
|
||||||
if (!summary.categorical && !nonFiniteExtent) {
|
);
|
||||||
zebra += 1;
|
}
|
||||||
return (
|
}
|
||||||
<HistogramBrush
|
})}
|
||||||
key={key}
|
|
||||||
field={key}
|
|
||||||
isObs
|
|
||||||
zebra={zebra % 2 === 0}
|
|
||||||
ranges={summary}
|
|
||||||
handleColorAction={this.handleColorAction(key)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ class GeneExpression extends React.Component {
|
|||||||
if (genes.length === 0) {
|
if (genes.length === 0) {
|
||||||
return keepAroundErrorToast("Must enter a gene name.");
|
return keepAroundErrorToast("Must enter a gene name.");
|
||||||
}
|
}
|
||||||
const worldGenes = world.varAnnotations.col(varIndexName).asArray();
|
const worldGenes =
|
||||||
|
world.varAnnotations?.col(varIndexName)?.asArray() || [];
|
||||||
|
|
||||||
// These gene lists are unique enough where memoization is useless
|
// These gene lists are unique enough where memoization is useless
|
||||||
const upperGenes = this._genesToUpper(genes);
|
const upperGenes = this._genesToUpper(genes);
|
||||||
@@ -206,8 +207,12 @@ class GeneExpression extends React.Component {
|
|||||||
differential
|
differential
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const varIndexName = world?.schema?.annotations?.var?.index;
|
const varIndexName = world?.schema?.annotations?.var?.index;
|
||||||
|
const varIndex = world?.varAnnotations?.col(varIndexName)?.asArray();
|
||||||
const { tab, bulkAdd, activeItem } = this.state;
|
const { tab, bulkAdd, activeItem } = this.state;
|
||||||
|
|
||||||
|
// may still be loading!
|
||||||
|
if (!varIndex) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
@@ -268,11 +273,7 @@ class GeneExpression extends React.Component {
|
|||||||
itemListPredicate={filterGenes}
|
itemListPredicate={filterGenes}
|
||||||
onActiveItemChange={item => this.setState({ activeItem: item })}
|
onActiveItemChange={item => this.setState({ activeItem: item })}
|
||||||
itemRenderer={renderGene.bind(this)}
|
itemRenderer={renderGene.bind(this)}
|
||||||
items={
|
items={varIndex || ["No genes"]}
|
||||||
world && world.varAnnotations
|
|
||||||
? world.varAnnotations.col(varIndexName).asArray()
|
|
||||||
: ["No genes"]
|
|
||||||
}
|
|
||||||
popoverProps={{ minimal: true }}
|
popoverProps={{ minimal: true }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ class Graph extends React.Component {
|
|||||||
const { regl, toolSVG, camera, modelTF } = this.state;
|
const { regl, toolSVG, camera, modelTF } = this.state;
|
||||||
let stateChanges = {};
|
let stateChanges = {};
|
||||||
|
|
||||||
if (regl && world) {
|
if (regl && world && crossfilter) {
|
||||||
/* update the regl and point rendering state */
|
/* update the regl and point rendering state */
|
||||||
const { obsLayout, nObs } = world;
|
const { obsLayout, nObs } = world;
|
||||||
const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
|
const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import * as globals from "../../globals";
|
|||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
universe: state.universe,
|
universe: state.universe,
|
||||||
world: state.world,
|
world: state.world,
|
||||||
loading: state.controls.loading,
|
|
||||||
crossfilter: state.crossfilter,
|
crossfilter: state.crossfilter,
|
||||||
differential: state.differential,
|
differential: state.differential,
|
||||||
resettingInterface: state.controls.resettingInterface,
|
resettingInterface: state.controls.resettingInterface,
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { ControlsHelpers as CH } from "../util/stateManager";
|
import { ControlsHelpers as CH } from "../util/stateManager";
|
||||||
import * as globals from "../globals";
|
import * as globals from "../globals";
|
||||||
|
|
||||||
function maxCategoryItems(state) {
|
|
||||||
return (
|
|
||||||
state.config.parameters?.["max-category-items"] ??
|
|
||||||
globals.configDefaults.parameters["max-category-items"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CategoricalSelection = (
|
const CategoricalSelection = (
|
||||||
state,
|
state,
|
||||||
action,
|
action,
|
||||||
@@ -22,11 +15,32 @@ const CategoricalSelection = (
|
|||||||
const { world } = nextSharedState;
|
const { world } = nextSharedState;
|
||||||
const newState = CH.createCategoricalSelection(
|
const newState = CH.createCategoricalSelection(
|
||||||
world,
|
world,
|
||||||
CH.selectableCategoryNames(world, maxCategoryItems(prevSharedState))
|
CH.selectableCategoryNames(
|
||||||
|
world.schema,
|
||||||
|
CH.maxCategoryItems(prevSharedState.config)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "universe: column load success": {
|
||||||
|
const { dim } = action;
|
||||||
|
if (dim !== "obsAnnotations") return state;
|
||||||
|
|
||||||
|
const { dataframe } = action;
|
||||||
|
const { world } = nextSharedState;
|
||||||
|
const names = CH.selectableCategoryNames(
|
||||||
|
world.schema,
|
||||||
|
CH.maxCategoryItems(prevSharedState.config),
|
||||||
|
dataframe.colIndex.keys()
|
||||||
|
);
|
||||||
|
if (names.length === 0) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...CH.createCategoricalSelection(world, names)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case "categorical metadata filter select": {
|
case "categorical metadata filter select": {
|
||||||
/*
|
/*
|
||||||
Set the specific category in this field to false
|
Set the specific category in this field to false
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const ColorsReducer = (
|
|||||||
prevSharedState
|
prevSharedState
|
||||||
) => {
|
) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "initial data load complete (universe exists)":
|
case "universe exists, but loading is still in progress":
|
||||||
case "reset World to eq Universe": {
|
case "reset World to eq Universe": {
|
||||||
const { world } = nextSharedState;
|
const { world } = nextSharedState;
|
||||||
const colorMode = null;
|
const colorMode = null;
|
||||||
|
|||||||
Vendored
+24
-1
@@ -41,8 +41,31 @@ const Controls = (
|
|||||||
case "initial data load start": {
|
case "initial data load start": {
|
||||||
return { ...state, loading: true };
|
return { ...state, loading: true };
|
||||||
}
|
}
|
||||||
|
case "universe: column load success": {
|
||||||
|
/*
|
||||||
|
we are in a loading state until the following are true:
|
||||||
|
* universe exists (if partially)
|
||||||
|
* embeddings (obsLayout) are loaded
|
||||||
|
* varAnnotations index is loaded
|
||||||
|
*/
|
||||||
|
const universeExists = !!nextSharedState.universe;
|
||||||
|
const embeddingsExist =
|
||||||
|
!nextSharedState.universe?.obsLayout?.isEmpty() ?? false;
|
||||||
|
const varIndex = nextSharedState.universe.schema.annotations.var.index;
|
||||||
|
const varAnnotationsIndexExists =
|
||||||
|
universeExists &&
|
||||||
|
nextSharedState.universe.varAnnotations.hasCol(varIndex);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: !(
|
||||||
|
universeExists &&
|
||||||
|
embeddingsExist &&
|
||||||
|
varAnnotationsIndexExists
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
case "initial data load complete (universe exists)": {
|
case "initial data load complete (universe exists)": {
|
||||||
/* first light - create world & other data-driven defaults */
|
/* now fully loaded */
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -23,13 +23,38 @@ const CrossfilterReducerBase = (
|
|||||||
prevSharedState
|
prevSharedState
|
||||||
) => {
|
) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "initial data load complete (universe exists)": {
|
case "universe: column load success": {
|
||||||
const { world, layoutChoice } = nextSharedState;
|
const { schema, world, layoutChoice } = nextSharedState;
|
||||||
const crossfilter = World.createObsDimensions(
|
const { obsAnnotations, obsLayout } = world;
|
||||||
new Crossfilter(world.obsAnnotations),
|
const { dim, dataframe } = action;
|
||||||
world,
|
|
||||||
layoutChoice.currentDimNames
|
// ignore var dimension loads as these are not currently selectable
|
||||||
);
|
if (action.dim === "varAnnotations") return state;
|
||||||
|
|
||||||
|
/*
|
||||||
|
during bootstrap loading, we don't know if obsLayout or obsAnnotations
|
||||||
|
will load first. Take whichever arrives and is not empty (so that our
|
||||||
|
crossfilter has the right dimensionality).
|
||||||
|
*/
|
||||||
|
let crossfilter =
|
||||||
|
state ??
|
||||||
|
new Crossfilter(obsAnnotations.isEmpty() ? obsLayout : obsAnnotations);
|
||||||
|
|
||||||
|
// add layout dimension, if not already present
|
||||||
|
if (
|
||||||
|
obsLayout.hasCol(layoutChoice.currentDimNames[0]) &&
|
||||||
|
!crossfilter.hasDimension(XYDimName)
|
||||||
|
) {
|
||||||
|
crossfilter = crossfilter.addDimension(
|
||||||
|
XYDimName,
|
||||||
|
"spatial",
|
||||||
|
obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
|
||||||
|
obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add any missing obsAnnotations
|
||||||
|
crossfilter = World.addObsDimensions(crossfilter, world);
|
||||||
return crossfilter;
|
return crossfilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,10 +303,19 @@ const CrossfilterReducer = (
|
|||||||
nextSharedState,
|
nextSharedState,
|
||||||
prevSharedState
|
prevSharedState
|
||||||
);
|
);
|
||||||
if (!nextState || nextState.all() === nextSharedState.world.obsAnnotations) {
|
/*
|
||||||
|
update the data in the crossfilter to point at the current obsAnnotations, IF
|
||||||
|
they are not empty. If empty, leave it alone (can occur during boostrap loading).
|
||||||
|
*/
|
||||||
|
const nextObsAnnotations = nextSharedState.world?.obsAnnotations;
|
||||||
|
if (
|
||||||
|
!nextState ||
|
||||||
|
nextState.all() === nextObsAnnotations ||
|
||||||
|
nextObsAnnotations.isEmpty()
|
||||||
|
) {
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
return nextState.setData(nextSharedState.world.obsAnnotations);
|
return nextState.setData(nextObsAnnotations);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CrossfilterReducer;
|
export default CrossfilterReducer;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const LayoutChoice = (
|
|||||||
nextSharedState
|
nextSharedState
|
||||||
) => {
|
) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "initial data load complete (universe exists)":
|
case "universe exists, but loading is still in progress":
|
||||||
case "reset World to eq Universe": {
|
case "reset World to eq Universe": {
|
||||||
// set default to default
|
// set default to default
|
||||||
const { schema } = nextSharedState.world;
|
const { schema } = nextSharedState.world;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const skipOnActions = new Set([
|
|||||||
"url changed",
|
"url changed",
|
||||||
"interface reset started",
|
"interface reset started",
|
||||||
"initial data load start",
|
"initial data load start",
|
||||||
|
"universe: column load success",
|
||||||
|
"universe exists, but loading is still in progress",
|
||||||
"configuration load complete",
|
"configuration load complete",
|
||||||
"increment graph render counter",
|
"increment graph render counter",
|
||||||
"window resize",
|
"window resize",
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { unassignedCategoryLabel } from "../globals";
|
import { unassignedCategoryLabel } from "../globals";
|
||||||
|
import {
|
||||||
|
addObsAnnotations,
|
||||||
|
addVarAnnotations,
|
||||||
|
addObsLayout
|
||||||
|
} from "../util/stateManager/universe";
|
||||||
import {
|
import {
|
||||||
World,
|
World,
|
||||||
ControlsHelpers,
|
ControlsHelpers,
|
||||||
@@ -7,11 +12,38 @@ import {
|
|||||||
|
|
||||||
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "initial data load complete (universe exists)": {
|
case "universe exists, but loading is still in progress": {
|
||||||
const { universe } = action;
|
const { universe } = action;
|
||||||
return universe;
|
return universe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "universe: column load success": {
|
||||||
|
const { dim, dataframe } = action;
|
||||||
|
switch (dim) {
|
||||||
|
case "obsAnnotations": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...addObsAnnotations(state, dataframe)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "varAnnotations": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...addVarAnnotations(state, dataframe)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "obsLayout": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...addObsLayout(state, dataframe)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error("action handler not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "expression load success": {
|
case "expression load success": {
|
||||||
let { varData } = state;
|
let { varData } = state;
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,27 @@ const WorldReducer = (
|
|||||||
prevSharedState
|
prevSharedState
|
||||||
) => {
|
) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "initial data load complete (universe exists)":
|
case "universe exists, but loading is still in progress":
|
||||||
case "reset World to eq Universe": {
|
case "reset World to eq Universe": {
|
||||||
const { universe } = nextSharedState;
|
const { universe } = nextSharedState;
|
||||||
const world = World.createWorldFromEntireUniverse(universe);
|
const world = World.createWorldFromEntireUniverse(universe);
|
||||||
return world;
|
return world;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "universe: column load success": {
|
||||||
|
const { universe } = nextSharedState;
|
||||||
|
const { dim } = action;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
schema: universe.schema,
|
||||||
|
[dim]: universe[dim].clone(),
|
||||||
|
unclipped: {
|
||||||
|
...state.unclipped,
|
||||||
|
[dim]: universe[dim].clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case "set World to current selection": {
|
case "set World to current selection": {
|
||||||
/* Set viewable world to be the currently selected data */
|
/* Set viewable world to be the currently selected data */
|
||||||
const world = World.createWorldBySelection(
|
const world = World.createWorldBySelection(
|
||||||
|
|||||||
@@ -351,11 +351,26 @@ class Dataframe {
|
|||||||
withColsFrom(dataframe) {
|
withColsFrom(dataframe) {
|
||||||
/*
|
/*
|
||||||
return a new dataframe containing all columns from both `this` and the
|
return a new dataframe containing all columns from both `this` and the
|
||||||
provided dataframe.
|
provided of dataframe.
|
||||||
|
|
||||||
The row index from `this` will be used. Both dataframes must have identical
|
The row index from `this` will be used. All dataframes must have identical
|
||||||
dimensionality, and no overlapping columns labels.
|
dimensionality, and no overlapping columns labels.
|
||||||
|
|
||||||
|
Special case, if either dataframe is empty, the other is returned unchanged.
|
||||||
*/
|
*/
|
||||||
|
if (this.isEmpty()) {
|
||||||
|
return dataframe;
|
||||||
|
}
|
||||||
|
if (dataframe.isEmpty()) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.colIndex.keys().forEach(key => {
|
||||||
|
if (dataframe.has(key)) {
|
||||||
|
throw new Error("duplicate key collision");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const dims = [this.dims[0], this.dims[1] + dataframe.dims[1]];
|
const dims = [this.dims[0], this.dims[1] + dataframe.dims[1]];
|
||||||
const { rowIndex } = this;
|
const { rowIndex } = this;
|
||||||
const columns = [...this.__columns, ...dataframe.__columns];
|
const columns = [...this.__columns, ...dataframe.__columns];
|
||||||
@@ -373,6 +388,11 @@ class Dataframe {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withColsFromAll(dataframes = []) {
|
||||||
|
dataframes = Array.isArray(dataframes) ? dataframes : [dataframes];
|
||||||
|
return dataframes.reduce((acc, df) => acc.withColsFrom(df), this);
|
||||||
|
}
|
||||||
|
|
||||||
dropCol(label) {
|
dropCol(label) {
|
||||||
/*
|
/*
|
||||||
Create a new dataframe, omitting one columns.
|
Create a new dataframe, omitting one columns.
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
diffexpDimensionName
|
diffexpDimensionName
|
||||||
} from "../nameCreators";
|
} from "../nameCreators";
|
||||||
|
|
||||||
|
export function maxCategoryItems(config) {
|
||||||
|
return (
|
||||||
|
config.parameters?.["max-category-items"] ??
|
||||||
|
globals.configDefaults.parameters["max-category-items"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Selection state for categoricals are tracked in an Object that
|
Selection state for categoricals are tracked in an Object that
|
||||||
has two main components for each category:
|
has two main components for each category:
|
||||||
@@ -62,15 +69,23 @@ function topNCategories(colSchema, summary, N) {
|
|||||||
return [topNCategories, topNCounts];
|
return [topNCategories, topNCounts];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectableCategoryNames(world, maxCategoryItems) {
|
export function selectableCategoryNames(schema, maxCategoryItems, names) {
|
||||||
const { schema } = world;
|
/*
|
||||||
|
return all obs annotation names that are categorical AND have a
|
||||||
|
"reasonably" small number of categories AND are not the index column.
|
||||||
|
|
||||||
|
If the initial name list not provided, use everything in the schema.
|
||||||
|
*/
|
||||||
|
if (!schema) return [];
|
||||||
const { index, columns } = schema.annotations.obs;
|
const { index, columns } = schema.annotations.obs;
|
||||||
|
|
||||||
return columns
|
return columns
|
||||||
|
.filter(colSchema => !names || names.indexOf(colSchema.name) !== -1)
|
||||||
.filter(colSchema => {
|
.filter(colSchema => {
|
||||||
const { name, categories } = colSchema;
|
const { type, name } = colSchema;
|
||||||
return (
|
const isSelectableType =
|
||||||
categories && categories.length < maxCategoryItems && name !== index
|
type === "string" || type === "boolean" || type === "categorical";
|
||||||
);
|
return isSelectableType && name !== index;
|
||||||
})
|
})
|
||||||
.map(v => v.name);
|
.map(v => v.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,15 +31,6 @@ export function indexEntireSchema(schema) {
|
|||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortAllCategorical(schema) {
|
|
||||||
/* UI relies on OBS annotation categories being in presentation sort order */
|
|
||||||
schema.annotations.obs.columns.forEach(c => {
|
|
||||||
if (c.categories) {
|
|
||||||
c.categories = catLabelSort(c.writable, c.categories);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _copy(schema) {
|
function _copy(schema) {
|
||||||
/* redux copy conventions - WARNING, only for modifyign obs annotations */
|
/* redux copy conventions - WARNING, only for modifyign obs annotations */
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { unassignedCategoryLabel } from "../../globals";
|
|||||||
import { decodeMatrixFBS } from "./matrix";
|
import { decodeMatrixFBS } from "./matrix";
|
||||||
import * as Dataframe from "../dataframe";
|
import * as Dataframe from "../dataframe";
|
||||||
import { isFpTypedArray } from "../typeHelpers";
|
import { isFpTypedArray } from "../typeHelpers";
|
||||||
import { indexEntireSchema, sortAllCategorical } from "./schemaHelpers";
|
import { indexEntireSchema } from "./schemaHelpers";
|
||||||
import { isCategoricalAnnotation } from "./annotationsHelpers";
|
import { isCategoricalAnnotation } from "./annotationsHelpers";
|
||||||
|
import catLabelSort from "../catLabelSort";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Private helper function - create and return a template Universe
|
Private helper function - create and return a template Universe
|
||||||
@@ -74,9 +75,9 @@ function promoteTypedArray(o) {
|
|||||||
return new TyepdArrayCtor(o);
|
return new TyepdArrayCtor(o);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnnotationsFBSToDataframe(arrayBuffer) {
|
export function matrixFBSToDataframe(arrayBuffers) {
|
||||||
/*
|
/*
|
||||||
Convert a Matrix FBS to a Dataframe.
|
Convert array of Matrix FBS to a Dataframe.
|
||||||
|
|
||||||
The application has strong assumptions that all scalar data will be
|
The application has strong assumptions that all scalar data will be
|
||||||
stored as a float32 or float64 (regardless of underlying data types).
|
stored as a float32 or float64 (regardless of underlying data types).
|
||||||
@@ -86,77 +87,41 @@ function AnnotationsFBSToDataframe(arrayBuffer) {
|
|||||||
All float data from the server is left as is. All non-float is promoted
|
All float data from the server is left as is. All non-float is promoted
|
||||||
to an appropriate float.
|
to an appropriate float.
|
||||||
*/
|
*/
|
||||||
const fbs = decodeMatrixFBS(arrayBuffer, true); // leave in place
|
if (!Array.isArray(arrayBuffers)) {
|
||||||
const columns = fbs.columns.map(c => {
|
arrayBuffers = [arrayBuffers];
|
||||||
if (isFpTypedArray(c) || Array.isArray(c)) return c;
|
}
|
||||||
return promoteTypedArray(c);
|
if (arrayBuffers.length === 0) {
|
||||||
});
|
return Dataframe.Dataframe.empty();
|
||||||
const df = new Dataframe.Dataframe(
|
|
||||||
[fbs.nRows, fbs.nCols],
|
|
||||||
columns,
|
|
||||||
null,
|
|
||||||
new Dataframe.KeyIndex(fbs.colIdx)
|
|
||||||
);
|
|
||||||
return df;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LayoutFBSToDataframe(arrayBuffer) {
|
|
||||||
const fbs = decodeMatrixFBS(arrayBuffer, true);
|
|
||||||
if (fbs.columns.length < 2 || !fbs.columns.every(isFpTypedArray)) {
|
|
||||||
// We have strong assumptions about the shape & type of layout data.
|
|
||||||
throw new Error("Unexpected layout data type returned from server");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fbs = arrayBuffers.map(ab => decodeMatrixFBS(ab, true)); // leave in place
|
||||||
|
/* check that all FBS have same row dimensionality */
|
||||||
|
const nRows = fbs[0].nRows;
|
||||||
|
fbs.forEach(b => {
|
||||||
|
if (b.nRows !== nRows)
|
||||||
|
throw new Error("FBS with inconsistent dimensionality");
|
||||||
|
});
|
||||||
|
const columns = fbs
|
||||||
|
.map(fb =>
|
||||||
|
fb.columns.map(c => {
|
||||||
|
if (isFpTypedArray(c) || Array.isArray(c)) return c;
|
||||||
|
return promoteTypedArray(c);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.flat();
|
||||||
|
const colIdx = fbs.map(b => b.colIdx).flat();
|
||||||
|
const nCols = columns.length;
|
||||||
|
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[fbs.nRows, fbs.nCols],
|
[nRows, nCols],
|
||||||
fbs.columns,
|
columns,
|
||||||
null,
|
null,
|
||||||
new Dataframe.KeyIndex(fbs.colIdx)
|
new Dataframe.KeyIndex(colIdx)
|
||||||
);
|
);
|
||||||
return df;
|
return df;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileSchemaCategoriesWithSummary(universe) {
|
export function createUniverseFromResponse(configResponse, schemaResponse) {
|
||||||
/*
|
|
||||||
where we treat types as (essentially) categorical metadata, update
|
|
||||||
the schema with data-derived categories (in addition to those in
|
|
||||||
the server declared schema).
|
|
||||||
|
|
||||||
For example, boolean defined fields in the schema do not contain
|
|
||||||
explicit declaration of categories (nor do string fields). In these
|
|
||||||
cases, add a 'categories' field to the schema so it is accessible.
|
|
||||||
|
|
||||||
In addition, we have a client-side convention (UI) that all writable
|
|
||||||
annotations must have an 'unassigned' category, even if it is not currently
|
|
||||||
in use.
|
|
||||||
*/
|
|
||||||
|
|
||||||
universe.schema.annotations.obs.columns.forEach(s => {
|
|
||||||
if (
|
|
||||||
s.type === "string" ||
|
|
||||||
s.type === "boolean" ||
|
|
||||||
s.type === "categorical"
|
|
||||||
) {
|
|
||||||
const categories = _.union(
|
|
||||||
s.categories ?? [],
|
|
||||||
universe.obsAnnotations.col(s.name).summarize().categories ?? []
|
|
||||||
);
|
|
||||||
s.categories = categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.writable && s.categories.indexOf(unassignedCategoryLabel) === -1) {
|
|
||||||
s.categories = s.categories.concat(unassignedCategoryLabel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createUniverseFromResponse(
|
|
||||||
configResponse,
|
|
||||||
schemaResponse,
|
|
||||||
annotationsObsResponse,
|
|
||||||
annotationsVarResponse,
|
|
||||||
layoutFBSResponse
|
|
||||||
) {
|
|
||||||
/*
|
/*
|
||||||
build & return universe from a REST 0.2 /config, /schema and /annotations/obs response
|
build & return universe from a REST 0.2 /config, /schema and /annotations/obs response
|
||||||
*/
|
*/
|
||||||
@@ -167,41 +132,80 @@ export function createUniverseFromResponse(
|
|||||||
universe.schema = schema;
|
universe.schema = schema;
|
||||||
universe.nObs = schema.dataframe.nObs;
|
universe.nObs = schema.dataframe.nObs;
|
||||||
universe.nVar = schema.dataframe.nVar;
|
universe.nVar = schema.dataframe.nVar;
|
||||||
|
|
||||||
/* add defaults, as we can't assume back-end will fully populate schema */
|
/* add defaults, as we can't assume back-end will fully populate schema */
|
||||||
if (!schema.layout.var) schema.layout.var = [];
|
if (!schema.layout.var) schema.layout.var = [];
|
||||||
if (!schema.layout.obs) schema.layout.obs = [];
|
if (!schema.layout.obs) schema.layout.obs = [];
|
||||||
|
|
||||||
/* annotations */
|
|
||||||
universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse);
|
|
||||||
universe.varAnnotations = AnnotationsFBSToDataframe(annotationsVarResponse);
|
|
||||||
/* layout */
|
|
||||||
universe.obsLayout = LayoutFBSToDataframe(layoutFBSResponse);
|
|
||||||
|
|
||||||
/* sanity checks */
|
|
||||||
if (
|
|
||||||
universe.nObs !== universe.obsLayout.length ||
|
|
||||||
universe.nObs !== universe.obsAnnotations.length ||
|
|
||||||
universe.nVar !== universe.varAnnotations.length
|
|
||||||
) {
|
|
||||||
throw new Error("Universe dimensionality mismatch - failed to load");
|
|
||||||
}
|
|
||||||
|
|
||||||
reconcileSchemaCategoriesWithSummary(universe);
|
|
||||||
sortAllCategorical(universe.schema);
|
|
||||||
indexEntireSchema(universe.schema);
|
indexEntireSchema(universe.schema);
|
||||||
|
normalizeEntireSchema(universe.schema);
|
||||||
|
|
||||||
/* sanity checks */
|
return universe;
|
||||||
if (
|
}
|
||||||
schema.annotations.obs.columns.some(
|
|
||||||
s => s.writable && !isCategoricalAnnotation(schema, s.name)
|
function normalizeSchemaCategory(colSchema, col = undefined) {
|
||||||
)
|
const { type, writable } = colSchema;
|
||||||
) {
|
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||||
|
let categories = [
|
||||||
|
...new Set([
|
||||||
|
...(colSchema.categories ?? []),
|
||||||
|
...(col?.summarize?.().categories ?? [])
|
||||||
|
])
|
||||||
|
];
|
||||||
|
if (writable && categories.indexOf(unassignedCategoryLabel) === -1) {
|
||||||
|
categories = categories.concat(unassignedCategoryLabel);
|
||||||
|
}
|
||||||
|
colSchema.categories = categories;
|
||||||
|
} else if (writable) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Writable continuous obs annotations are not supported - failed to load"
|
"Writable continuous obs annotations are not supported - failed to load"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return universe;
|
if (colSchema.categories) {
|
||||||
|
colSchema.categories = catLabelSort(writable, colSchema.categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntireSchema(schema) {
|
||||||
|
// currently only needed for obsAnnotations
|
||||||
|
schema.annotations.obs.columns.forEach(colSchema =>
|
||||||
|
normalizeSchemaCategory(colSchema)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addObsAnnotations(universe, df) {
|
||||||
|
const obsAnnotations = universe.obsAnnotations.withColsFromAll(df);
|
||||||
|
if (universe.nObs !== obsAnnotations.length) {
|
||||||
|
throw new Error("Universe dimensionality mismatch - failed to load");
|
||||||
|
}
|
||||||
|
|
||||||
|
// for all of the new data, reconcile with schema and sort categories.
|
||||||
|
const dfs = Array.isArray(df) ? df : [df];
|
||||||
|
const keys = dfs.map(df => df.colIndex.keys()).flat();
|
||||||
|
const { schema } = universe;
|
||||||
|
keys.forEach(k => {
|
||||||
|
const colSchema = schema.annotations.obsByName[k];
|
||||||
|
const col = obsAnnotations.col(k);
|
||||||
|
normalizeSchemaCategory(colSchema, col);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { obsAnnotations, schema };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addVarAnnotations(universe, df) {
|
||||||
|
const varAnnotations = universe.varAnnotations.withColsFromAll(df);
|
||||||
|
if (universe.nVar !== varAnnotations.length) {
|
||||||
|
throw new Error("Universe dimensionality mismatch - failed to load");
|
||||||
|
}
|
||||||
|
return { varAnnotations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addObsLayout(universe, df) {
|
||||||
|
const obsLayout = universe.obsLayout.withColsFromAll(df);
|
||||||
|
if (universe.nObs !== obsLayout.length) {
|
||||||
|
throw new Error("Universe dimensionality mismatch - failed to load");
|
||||||
|
}
|
||||||
|
return { obsLayout };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertDataFBStoObject(universe, arrayBuffer) {
|
export function convertDataFBStoObject(universe, arrayBuffer) {
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ function clipDataframe(
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
Create World with contents eq entire universe. Commonly used to initialize World.
|
Create World with contents eq entire universe. Commonly used to initialize World.
|
||||||
If clipQuantiles
|
|
||||||
*/
|
*/
|
||||||
export function createWorldFromEntireUniverse(universe) {
|
export function createWorldFromEntireUniverse(universe) {
|
||||||
const world = templateWorld();
|
const world = templateWorld();
|
||||||
@@ -253,6 +252,40 @@ function deduceDimensionType(attributes, fieldName) {
|
|||||||
return dimensionType;
|
return dimensionType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addObsDimension(crossfilter, world, anno) {
|
||||||
|
/*
|
||||||
|
add single dimension to the crosfilter
|
||||||
|
*/
|
||||||
|
const { obsAnnotations } = world;
|
||||||
|
if (obsAnnotations.hasCol(anno.name)) {
|
||||||
|
const dimType = deduceDimensionType(anno, anno.name);
|
||||||
|
const colData = obsAnnotations.col(anno.name).asArray();
|
||||||
|
const name = obsAnnoDimensionName(anno.name);
|
||||||
|
if (dimType === "enum") {
|
||||||
|
return crossfilter.addDimension(name, "enum", colData);
|
||||||
|
}
|
||||||
|
if (dimType) {
|
||||||
|
return crossfilter.addDimension(name, "scalar", colData, dimType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addObsDimensions(crossfilter, world) {
|
||||||
|
/*
|
||||||
|
Add to crossfilter any dimension present in world.obsAnnotations
|
||||||
|
but not yet in the crossfilter
|
||||||
|
*/
|
||||||
|
const schema = world.schema.annotations.obsByName;
|
||||||
|
const dimsWeNeed = world.obsAnnotations.colIndex.keys();
|
||||||
|
crossfilter = dimsWeNeed.reduce((xfltr, name) => {
|
||||||
|
const dimName = obsAnnoDimensionName(name);
|
||||||
|
if (xfltr.hasDimension(dimName)) return xfltr;
|
||||||
|
return addObsDimension(xfltr, world, schema[name]);
|
||||||
|
}, crossfilter);
|
||||||
|
return crossfilter;
|
||||||
|
}
|
||||||
|
|
||||||
export function createObsDimensions(crossfilter, world, XYdimNames) {
|
export function createObsDimensions(crossfilter, world, XYdimNames) {
|
||||||
/*
|
/*
|
||||||
create and return a crossfilter with a dimension for every obs annotation
|
create and return a crossfilter with a dimension for every obs annotation
|
||||||
@@ -265,16 +298,7 @@ export function createObsDimensions(crossfilter, world, XYdimNames) {
|
|||||||
anno => anno.name !== indexName
|
anno => anno.name !== indexName
|
||||||
);
|
);
|
||||||
crossfilter = annoList.reduce((xfltr, anno) => {
|
crossfilter = annoList.reduce((xfltr, anno) => {
|
||||||
const dimType = deduceDimensionType(anno, anno.name);
|
return addObsDimension(xfltr, world, anno);
|
||||||
const colData = obsAnnotations.col(anno.name).asArray();
|
|
||||||
const name = obsAnnoDimensionName(anno.name);
|
|
||||||
if (dimType === "enum") {
|
|
||||||
return xfltr.addDimension(name, "enum", colData);
|
|
||||||
}
|
|
||||||
if (dimType) {
|
|
||||||
return xfltr.addDimension(name, "scalar", colData, dimType);
|
|
||||||
}
|
|
||||||
return xfltr;
|
|
||||||
}, crossfilter);
|
}, crossfilter);
|
||||||
|
|
||||||
return crossfilter.addDimension(
|
return crossfilter.addDimension(
|
||||||
|
|||||||
Reference in New Issue
Block a user