diff --git a/client/__tests__/util/stateManager/sampleResponses.js b/client/__tests__/util/stateManager/sampleResponses.js
index 0da5b81a..8a3e6f9a 100644
--- a/client/__tests__/util/stateManager/sampleResponses.js
+++ b/client/__tests__/util/stateManager/sampleResponses.js
@@ -56,6 +56,10 @@ const aSchemaResponse = {
categories: fieldDCategories
}
]
+ },
+ layout: {
+ obs: [{ name: "umap", type: "float32", dims: ["umap_0", "umap_1"] }],
+ var: []
}
}
};
diff --git a/client/__tests__/util/stateManager/universe.test.js b/client/__tests__/util/stateManager/universe.test.js
index b58e176b..6e4dc306 100644
--- a/client/__tests__/util/stateManager/universe.test.js
+++ b/client/__tests__/util/stateManager/universe.test.js
@@ -56,7 +56,9 @@ describe("createUniverseFromResponse", () => {
REST.schema.schema.annotations.obs.length
]);
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
- expect(universe.obsLayout.colIndex.keys()).toEqual(["X", "Y"]);
+ expect(universe.obsLayout.colIndex.keys()).toEqual(
+ universe.schema.layout.obs[0].dims
+ );
expect(universe.varAnnotations.dims).toEqual([
nVar,
REST.schema.schema.annotations.var.length
diff --git a/client/__tests__/util/stateManager/world.test.js b/client/__tests__/util/stateManager/world.test.js
index 8cc5fc31..c3cae4a3 100644
--- a/client/__tests__/util/stateManager/world.test.js
+++ b/client/__tests__/util/stateManager/world.test.js
@@ -29,7 +29,8 @@ const defaultBigBang = () => {
/* create crossfilter */
const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations),
- world
+ world,
+ REST.schema.schema.layout.obs[0].dims
);
return {
@@ -138,7 +139,9 @@ describe("createWorldFromCurrentSelection", () => {
expect(world.obsLayout.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices)
);
- expect(world.obsLayout.colIndex.keys()).toEqual(["X", "Y"]);
+ expect(world.obsLayout.colIndex.keys()).toEqual(
+ world.schema.layout.obs[0].dims
+ );
});
});
diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js
index 5871f70f..bd4ccfed 100644
--- a/client/src/components/graph/graph.js
+++ b/client/src/components/graph/graph.js
@@ -13,7 +13,9 @@ import {
MenuItem,
Position,
NumericInput,
- Icon
+ Icon,
+ RadioGroup,
+ Radio
} from "@blueprintjs/core";
import * as globals from "../../globals";
@@ -47,7 +49,8 @@ import { World } from "../../util/stateManager";
undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0,
selectionTool: state.graphSelection.tool,
- currentSelection: state.graphSelection.selection
+ currentSelection: state.graphSelection.selection,
+ layoutChoice: state.layoutChoice
}))
class Graph extends React.Component {
static isValidDigitKeyEvent(e) {
@@ -81,6 +84,8 @@ class Graph extends React.Component {
this.graphPaddingBottom = 45;
this.graphPaddingRight = globals.leftSidebarWidth;
this.renderCache = {
+ X: null,
+ Y: null,
positions: null,
colors: null,
sizes: null
@@ -140,7 +145,8 @@ class Graph extends React.Component {
colorRGB,
responsive,
selectionTool,
- currentSelection
+ currentSelection,
+ layoutChoice
} = this.props;
const {
reglRender,
@@ -163,13 +169,15 @@ class Graph extends React.Component {
if (regl && world) {
/* update the regl state */
const { obsLayout, nObs } = world;
- const X = obsLayout.col("X").asArray();
- const Y = obsLayout.col("Y").asArray();
+ const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
+ const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
+ const { X: prevX, Y: prevY } = renderCache;
// X/Y positions for each point - a cached value that only
// changes if we have loaded entirely new cell data
//
- if (!renderCache.positions || world !== prevProps.world) {
+ /* TODO/XXX: we should just memoize this code */
+ if (!renderCache.positions || X !== prevX || Y !== prevY) {
renderCache.positions = new Float32Array(2 * nObs);
const glScaleX = scaleLinear([0, 1], [-1, 1]);
@@ -188,6 +196,8 @@ class Graph extends React.Component {
glScaleX,
glScaleY
};
+ renderCache.X = X;
+ renderCache.Y = Y;
}
// Colors for each point - a cached value that only changes when
@@ -436,6 +446,14 @@ class Graph extends React.Component {
this.setState({ pendingClipPercentiles: null });
};
+ handleLayoutChoiceChange = e => {
+ const { dispatch } = this.props;
+ dispatch({
+ type: "set layout choice",
+ layoutChoice: e.currentTarget.value
+ });
+ };
+
brushToolUpdate(tool, container, transform) {
/*
this is called from componentDidUpdate(), so be very careful using
@@ -751,7 +769,8 @@ class Graph extends React.Component {
redoDisabled,
selectionTool,
clipPercentileMin,
- clipPercentileMax
+ clipPercentileMax,
+ layoutChoice
} = this.props;
const { mode, pendingClipPercentiles } = this.state;
@@ -894,6 +913,49 @@ class Graph extends React.Component {
/>
+
+
+
+ }
+ position={Position.BOTTOM_RIGHT}
+ content={
+
+
+ {layoutChoice.available.map(name => (
+
+ ))}
+
+
+ }
+ />
+
+
{
switch (action.type) {
case "initial data load complete (universe exists)": {
- const { world } = nextSharedState;
+ const { world, layoutChoice } = nextSharedState;
const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations),
- world
+ world,
+ layoutChoice.currentDimNames
);
return crossfilter;
}
@@ -43,9 +44,13 @@ const CrossfilterReducer = (
case "set clip quantiles":
case "set World to current selection": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
- const { world } = nextSharedState;
+ const { world, layoutChoice } = nextSharedState;
let crossfilter = new Crossfilter(world.obsAnnotations);
- crossfilter = World.createObsDimensions(crossfilter, world);
+ crossfilter = World.createObsDimensions(
+ crossfilter,
+ world,
+ layoutChoice.currentDimNames
+ );
crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes,
diffexpGenes,
@@ -55,6 +60,23 @@ const CrossfilterReducer = (
return crossfilter;
}
+ case "set layout choice": {
+ /*
+ when switching layouts:
+ - delete the existing XY index
+ - add the new XY index (which implicitly selects all on it)
+ */
+ const { world, layoutChoice } = nextSharedState;
+ return state
+ .delDimension(layoutDimensionName("XY"))
+ .addDimension(
+ layoutDimensionName("XY"),
+ "spatial",
+ world.obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
+ world.obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
+ );
+ }
+
case "request user defined gene success": {
const { world } = prevSharedState;
const gene = action.data.genes[0];
diff --git a/client/src/reducers/graphSelection.js b/client/src/reducers/graphSelection.js
index 6fde8926..00db09cb 100644
--- a/client/src/reducers/graphSelection.js
+++ b/client/src/reducers/graphSelection.js
@@ -7,7 +7,8 @@ const GraphSelection = (
) => {
switch (action.type) {
case "set clip quantiles":
- case "reset World to eq Universe": {
+ case "reset World to eq Universe":
+ case "set layout choice": {
return {
...state,
selection: {
diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js
index 9c1d6c33..f223a9e9 100644
--- a/client/src/reducers/index.js
+++ b/client/src/reducers/index.js
@@ -12,6 +12,7 @@ import graphSelection from "./graphSelection";
import crossfilter from "./crossfilter";
import colors from "./colors";
import differential from "./differential";
+import layoutChoice from "./layoutChoice";
import responsive from "./responsive";
import controls from "./controls";
import resetCache from "./resetCache";
@@ -19,31 +20,33 @@ import resetCache from "./resetCache";
import undoableConfig from "./undoableConfig";
const Reducer = undoable(
- cascadeReducers([
- ["config", config],
- ["universe", universe],
- ["world", world],
- ["categoricalSelection", categoricalSelection],
- ["continuousSelection", continuousSelection],
- ["graphSelection", graphSelection],
- ["crossfilter", crossfilter],
- ["colors", colors],
- ["controls", controls],
- ["differential", differential],
- ["responsive", responsive],
- ["resetCache", resetCache]
- ]),
- [
- "world",
- "categoricalSelection",
- "continuousSelection",
- "graphSelection",
- "crossfilter",
- "colors",
- "controls",
- "differential"
- ],
- undoableConfig
+ cascadeReducers([
+ ["config", config],
+ ["universe", universe],
+ ["world", world],
+ ["layoutChoice", layoutChoice],
+ ["categoricalSelection", categoricalSelection],
+ ["continuousSelection", continuousSelection],
+ ["graphSelection", graphSelection],
+ ["crossfilter", crossfilter],
+ ["colors", colors],
+ ["controls", controls],
+ ["differential", differential],
+ ["responsive", responsive],
+ ["resetCache", resetCache]
+ ]),
+ [
+ "world",
+ "categoricalSelection",
+ "continuousSelection",
+ "graphSelection",
+ "crossfilter",
+ "colors",
+ "controls",
+ "differential",
+ "layoutChoice"
+ ],
+ undoableConfig
);
const store = createStore(Reducer, applyMiddleware(thunk));
diff --git a/client/src/reducers/layoutChoice.js b/client/src/reducers/layoutChoice.js
new file mode 100644
index 00000000..8f64d0e9
--- /dev/null
+++ b/client/src/reducers/layoutChoice.js
@@ -0,0 +1,50 @@
+/*
+we have a UI heuristic to pick the default layout, based on assumptions
+about commonly used names. Preferentially, pick in the following order:
+
+ 1. "umap"
+ 2. "tsne"
+ 3. "pca"
+ 4. give up, use the first available
+*/
+function bestDefaultLayout(layouts) {
+ const preferredNames = ["umap", "tsne", "pca"];
+ const idx = preferredNames.findIndex(name => layouts.indexOf(name) !== -1);
+ if (idx !== -1) return preferredNames[idx];
+ return layouts[0];
+}
+
+const LayoutChoice = (
+ state = {
+ available: [], // all available choices
+ current: undefined, // name of the current layout, eg, 'umap'
+ currentDimNames: [] // dimension name
+ },
+ action,
+ nextSharedState
+) => {
+ switch (action.type) {
+ case "initial data load complete (universe exists)":
+ case "reset World to eq Universe": {
+ // set default to default
+ const { schema } = nextSharedState.world;
+ const available = schema.layout.obs.map(v => v.name);
+ const current = bestDefaultLayout(available);
+ const currentDimNames = schema.layout.obsByName[current].dims;
+ return { available, current, currentDimNames };
+ }
+
+ case "set layout choice": {
+ const { schema } = nextSharedState.world;
+ const current = action.layoutChoice;
+ const currentDimNames = schema.layout.obsByName[current].dims;
+ return { ...state, current, currentDimNames };
+ }
+
+ default: {
+ return state;
+ }
+ }
+};
+
+export default LayoutChoice;
diff --git a/client/src/reducers/undoableConfig.js b/client/src/reducers/undoableConfig.js
index 21745628..93468c62 100644
--- a/client/src/reducers/undoableConfig.js
+++ b/client/src/reducers/undoableConfig.js
@@ -72,7 +72,9 @@ const saveOnActions = new Set([
"store current cell selection as differential set 2",
"set World to current selection",
- "set clip quantiles"
+ "set clip quantiles",
+
+ "set layout choice"
]);
/**
diff --git a/client/src/util/stateManager/universe.js b/client/src/util/stateManager/universe.js
index aeba0a39..bf64d10c 100644
--- a/client/src/util/stateManager/universe.js
+++ b/client/src/util/stateManager/universe.js
@@ -104,27 +104,11 @@ function LayoutFBSToDataframe(arrayBuffer) {
throw new Error("Unexpected layout data type returned from server");
}
- /*
- TODO: XXX
-
- TEMPORARY CODE AND COMMENT to support the progressive implementation
- of multi-layout support. For now, we search for one of the following
- in the layouts and use it if we find it: umap, then tsne, then pca,
- then whatever is first in the list.
- */
- let layoutIndex = 0;
- ["umap", "tsne", "pca"].some(name => {
- const idx = fbs.colIdx.indexOf(`${name}_0`);
- if (idx !== -1) {
- layoutIndex = idx;
- }
- return idx !== -1;
- });
const df = new Dataframe.Dataframe(
- [fbs.nRows, 2],
- [fbs.columns[layoutIndex], fbs.columns[layoutIndex + 1]],
+ [fbs.nRows, fbs.nCols],
+ fbs.columns,
null,
- new Dataframe.KeyIndex(["X", "Y"])
+ new Dataframe.KeyIndex(fbs.colIdx)
);
return df;
}
@@ -172,6 +156,9 @@ export function createUniverseFromResponse(
universe.schema = schema;
universe.nObs = schema.dataframe.nObs;
universe.nVar = schema.dataframe.nVar;
+ /* add defaults, as we can't assume back-end will fully populate schema */
+ if (!schema.layout.var) schema.layout.var = [];
+ if (!schema.layout.obs) schema.layout.obs = [];
/* annotations */
universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse);
@@ -197,6 +184,12 @@ export function createUniverseFromResponse(
universe.schema.annotations.varByName = fromEntries(
universe.schema.annotations.var.map(v => [v.name, v])
);
+ universe.schema.layout.obsByName = fromEntries(
+ universe.schema.layout.obs.map(v => [v.name, v])
+ );
+ universe.schema.layout.varByName = fromEntries(
+ universe.schema.layout.var.map(v => [v.name, v])
+ );
return universe;
}
diff --git a/client/src/util/stateManager/world.js b/client/src/util/stateManager/world.js
index ad3366fe..6852e745 100644
--- a/client/src/util/stateManager/world.js
+++ b/client/src/util/stateManager/world.js
@@ -260,7 +260,7 @@ function deduceDimensionType(attributes, fieldName) {
return dimensionType;
}
-export function createObsDimensions(crossfilter, world) {
+export function createObsDimensions(crossfilter, world, XYdimNames) {
/*
create and return a crossfilter with a dimension for every obs annotation
for which we have a supported type, *except* 'name'
@@ -283,8 +283,8 @@ export function createObsDimensions(crossfilter, world) {
return crossfilter.addDimension(
layoutDimensionName("XY"),
"spatial",
- obsLayout.col("X").asArray(),
- obsLayout.col("Y").asArray()
+ obsLayout.col(XYdimNames[0]).asArray(),
+ obsLayout.col(XYdimNames[1]).asArray()
);
}
diff --git a/server/app/scanpy_engine/scanpy_engine.py b/server/app/scanpy_engine/scanpy_engine.py
index 039bd136..8bb88b6b 100644
--- a/server/app/scanpy_engine/scanpy_engine.py
+++ b/server/app/scanpy_engine/scanpy_engine.py
@@ -115,6 +115,7 @@ class ScanpyEngine(CXGDriver):
"type": str(self.data.X.dtype),
},
"annotations": {"obs": [], "var": []},
+ "layout": {"obs": []}
}
for ax in Axis:
curr_axis = getattr(self.data, str(ax))
@@ -140,6 +141,14 @@ class ScanpyEngine(CXGDriver):
)
self.schema["annotations"][ax].append(ann_schema)
+ for layout in self.config['layout']:
+ layout_schema = {
+ "name": layout,
+ "type": "float32",
+ "dims": [f"{layout}_0", f"{layout}_1"]
+ }
+ self.schema["layout"]["obs"].append(layout_schema)
+
def _load_data(self, data):
# as of AnnData 0.6.19, backed mode performs initial load fast, but at the
# cost of significantly slower access to X data.
diff --git a/server/test/schema.json b/server/test/schema.json
index aa28eb62..e6afb72c 100644
--- a/server/test/schema.json
+++ b/server/test/schema.json
@@ -47,5 +47,14 @@
"type": "int32"
}
]
+ },
+ "layout": {
+ "obs": [
+ {
+ "name": "umap",
+ "type": "float32",
+ "dims": ["umap_0", "umap_1"]
+ }
+ ]
}
}