layout change UI (#776)

* add layout to schema

* add layout choice action and reducer

* multi layout UI

* update unit tests

* add missing file

* update test schema

* fix duplicate test id

* fix tabs

* PR lint

* fix pytest
This commit is contained in:
Bruce Martin
2019-05-22 13:21:33 -07:00
committed by GitHub
parent 63af79d3f8
commit 2357d0c1b8
13 changed files with 223 additions and 63 deletions
@@ -56,6 +56,10 @@ const aSchemaResponse = {
categories: fieldDCategories
}
]
},
layout: {
obs: [{ name: "umap", type: "float32", dims: ["umap_0", "umap_1"] }],
var: []
}
}
};
@@ -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
@@ -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
);
});
});
+69 -7
View File
@@ -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 {
/>
</Tooltip>
</div>
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Popover
target={
<Button
type="button"
data-testid="layout-choice"
className="bp3-button bp3-icon-heatmap"
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<RadioGroup
label="Layout Choice"
onChange={this.handleLayoutChoiceChange}
selectedValue={layoutChoice.current}
>
{layoutChoice.available.map(name => (
<Radio label={name} value={name} key={name} />
))}
</RadioGroup>
</div>
}
/>
</div>
<div
className="bp3-button-group"
style={{
+26 -4
View File
@@ -20,10 +20,11 @@ const CrossfilterReducer = (
) => {
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];
+2 -1
View File
@@ -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: {
+28 -25
View File
@@ -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));
+50
View File
@@ -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;
+3 -1
View File
@@ -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"
]);
/**
+12 -19
View File
@@ -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;
}
+3 -3
View File
@@ -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()
);
}
@@ -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.
+9
View File
@@ -47,5 +47,14 @@
"type": "int32"
}
]
},
"layout": {
"obs": [
{
"name": "umap",
"type": "float32",
"dims": ["umap_0", "umap_1"]
}
]
}
}