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:
Bruce Martin
2020-02-10 11:22:27 -08:00
committed by GitHub
parent 1c9b9f6a08
commit e770db1e2c
25 changed files with 634 additions and 259 deletions
+2 -2
View File
@@ -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
View File
@@ -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
+58 -3
View File
@@ -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}
+70 -58
View File
@@ -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
+1 -1
View File
@@ -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;
-1
View File
@@ -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,
+22 -8
View File
@@ -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
+1 -1
View File
@@ -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;
+24 -1
View File
@@ -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,
+43 -9
View File
@@ -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;
+1 -1
View File
@@ -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;
+2
View File
@@ -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",
+33 -1
View File
@@ -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;
+15 -1
View File
@@ -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(
+22 -2
View File
@@ -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 {
+95 -91
View File
@@ -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) {
+35 -11
View File
@@ -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(