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 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 REST.schema.schema.annotations.obs.length
]); ]);
expect(universe.obsLayout.dims).toEqual([nObs, 2]); 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([ expect(universe.varAnnotations.dims).toEqual([
nVar, nVar,
REST.schema.schema.annotations.var.length REST.schema.schema.annotations.var.length
@@ -29,7 +29,8 @@ const defaultBigBang = () => {
/* create crossfilter */ /* create crossfilter */
const crossfilter = World.createObsDimensions( const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations), new Crossfilter(world.obsAnnotations),
world world,
REST.schema.schema.layout.obs[0].dims
); );
return { return {
@@ -138,7 +139,9 @@ describe("createWorldFromCurrentSelection", () => {
expect(world.obsLayout.rowIndex.keys()).toEqual( expect(world.obsLayout.rowIndex.keys()).toEqual(
new Int32Array(matchingIndices) 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, MenuItem,
Position, Position,
NumericInput, NumericInput,
Icon Icon,
RadioGroup,
Radio
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import * as globals from "../../globals"; import * as globals from "../../globals";
@@ -47,7 +49,8 @@ import { World } from "../../util/stateManager";
undoDisabled: state["@@undoable/past"].length === 0, undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0, redoDisabled: state["@@undoable/future"].length === 0,
selectionTool: state.graphSelection.tool, selectionTool: state.graphSelection.tool,
currentSelection: state.graphSelection.selection currentSelection: state.graphSelection.selection,
layoutChoice: state.layoutChoice
})) }))
class Graph extends React.Component { class Graph extends React.Component {
static isValidDigitKeyEvent(e) { static isValidDigitKeyEvent(e) {
@@ -81,6 +84,8 @@ class Graph extends React.Component {
this.graphPaddingBottom = 45; this.graphPaddingBottom = 45;
this.graphPaddingRight = globals.leftSidebarWidth; this.graphPaddingRight = globals.leftSidebarWidth;
this.renderCache = { this.renderCache = {
X: null,
Y: null,
positions: null, positions: null,
colors: null, colors: null,
sizes: null sizes: null
@@ -140,7 +145,8 @@ class Graph extends React.Component {
colorRGB, colorRGB,
responsive, responsive,
selectionTool, selectionTool,
currentSelection currentSelection,
layoutChoice
} = this.props; } = this.props;
const { const {
reglRender, reglRender,
@@ -163,13 +169,15 @@ class Graph extends React.Component {
if (regl && world) { if (regl && world) {
/* update the regl state */ /* update the regl state */
const { obsLayout, nObs } = world; const { obsLayout, nObs } = world;
const X = obsLayout.col("X").asArray(); const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
const Y = obsLayout.col("Y").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 // X/Y positions for each point - a cached value that only
// changes if we have loaded entirely new cell data // 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); renderCache.positions = new Float32Array(2 * nObs);
const glScaleX = scaleLinear([0, 1], [-1, 1]); const glScaleX = scaleLinear([0, 1], [-1, 1]);
@@ -188,6 +196,8 @@ class Graph extends React.Component {
glScaleX, glScaleX,
glScaleY glScaleY
}; };
renderCache.X = X;
renderCache.Y = Y;
} }
// Colors for each point - a cached value that only changes when // Colors for each point - a cached value that only changes when
@@ -436,6 +446,14 @@ class Graph extends React.Component {
this.setState({ pendingClipPercentiles: null }); this.setState({ pendingClipPercentiles: null });
}; };
handleLayoutChoiceChange = e => {
const { dispatch } = this.props;
dispatch({
type: "set layout choice",
layoutChoice: e.currentTarget.value
});
};
brushToolUpdate(tool, container, transform) { brushToolUpdate(tool, container, transform) {
/* /*
this is called from componentDidUpdate(), so be very careful using this is called from componentDidUpdate(), so be very careful using
@@ -751,7 +769,8 @@ class Graph extends React.Component {
redoDisabled, redoDisabled,
selectionTool, selectionTool,
clipPercentileMin, clipPercentileMin,
clipPercentileMax clipPercentileMax,
layoutChoice
} = this.props; } = this.props;
const { mode, pendingClipPercentiles } = this.state; const { mode, pendingClipPercentiles } = this.state;
@@ -894,6 +913,49 @@ class Graph extends React.Component {
/> />
</Tooltip> </Tooltip>
</div> </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 <div
className="bp3-button-group" className="bp3-button-group"
style={{ style={{
+26 -4
View File
@@ -20,10 +20,11 @@ const CrossfilterReducer = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "initial data load complete (universe exists)": { case "initial data load complete (universe exists)": {
const { world } = nextSharedState; const { world, layoutChoice } = nextSharedState;
const crossfilter = World.createObsDimensions( const crossfilter = World.createObsDimensions(
new Crossfilter(world.obsAnnotations), new Crossfilter(world.obsAnnotations),
world world,
layoutChoice.currentDimNames
); );
return crossfilter; return crossfilter;
} }
@@ -43,9 +44,13 @@ const CrossfilterReducer = (
case "set clip quantiles": case "set clip quantiles":
case "set World to current selection": { case "set World to current selection": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls; const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world } = nextSharedState; const { world, layoutChoice } = nextSharedState;
let crossfilter = new Crossfilter(world.obsAnnotations); let crossfilter = new Crossfilter(world.obsAnnotations);
crossfilter = World.createObsDimensions(crossfilter, world); crossfilter = World.createObsDimensions(
crossfilter,
world,
layoutChoice.currentDimNames
);
crossfilter = ControlsHelpers.createGeneDimensions( crossfilter = ControlsHelpers.createGeneDimensions(
userDefinedGenes, userDefinedGenes,
diffexpGenes, diffexpGenes,
@@ -55,6 +60,23 @@ const CrossfilterReducer = (
return crossfilter; 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": { case "request user defined gene success": {
const { world } = prevSharedState; const { world } = prevSharedState;
const gene = action.data.genes[0]; const gene = action.data.genes[0];
+2 -1
View File
@@ -7,7 +7,8 @@ const GraphSelection = (
) => { ) => {
switch (action.type) { switch (action.type) {
case "set clip quantiles": case "set clip quantiles":
case "reset World to eq Universe": { case "reset World to eq Universe":
case "set layout choice": {
return { return {
...state, ...state,
selection: { selection: {
+28 -25
View File
@@ -12,6 +12,7 @@ import graphSelection from "./graphSelection";
import crossfilter from "./crossfilter"; import crossfilter from "./crossfilter";
import colors from "./colors"; import colors from "./colors";
import differential from "./differential"; import differential from "./differential";
import layoutChoice from "./layoutChoice";
import responsive from "./responsive"; import responsive from "./responsive";
import controls from "./controls"; import controls from "./controls";
import resetCache from "./resetCache"; import resetCache from "./resetCache";
@@ -19,31 +20,33 @@ import resetCache from "./resetCache";
import undoableConfig from "./undoableConfig"; import undoableConfig from "./undoableConfig";
const Reducer = undoable( const Reducer = undoable(
cascadeReducers([ cascadeReducers([
["config", config], ["config", config],
["universe", universe], ["universe", universe],
["world", world], ["world", world],
["categoricalSelection", categoricalSelection], ["layoutChoice", layoutChoice],
["continuousSelection", continuousSelection], ["categoricalSelection", categoricalSelection],
["graphSelection", graphSelection], ["continuousSelection", continuousSelection],
["crossfilter", crossfilter], ["graphSelection", graphSelection],
["colors", colors], ["crossfilter", crossfilter],
["controls", controls], ["colors", colors],
["differential", differential], ["controls", controls],
["responsive", responsive], ["differential", differential],
["resetCache", resetCache] ["responsive", responsive],
]), ["resetCache", resetCache]
[ ]),
"world", [
"categoricalSelection", "world",
"continuousSelection", "categoricalSelection",
"graphSelection", "continuousSelection",
"crossfilter", "graphSelection",
"colors", "crossfilter",
"controls", "colors",
"differential" "controls",
], "differential",
undoableConfig "layoutChoice"
],
undoableConfig
); );
const store = createStore(Reducer, applyMiddleware(thunk)); 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", "store current cell selection as differential set 2",
"set World to current selection", "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"); 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( const df = new Dataframe.Dataframe(
[fbs.nRows, 2], [fbs.nRows, fbs.nCols],
[fbs.columns[layoutIndex], fbs.columns[layoutIndex + 1]], fbs.columns,
null, null,
new Dataframe.KeyIndex(["X", "Y"]) new Dataframe.KeyIndex(fbs.colIdx)
); );
return df; return df;
} }
@@ -172,6 +156,9 @@ 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 */
if (!schema.layout.var) schema.layout.var = [];
if (!schema.layout.obs) schema.layout.obs = [];
/* annotations */ /* annotations */
universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse); universe.obsAnnotations = AnnotationsFBSToDataframe(annotationsObsResponse);
@@ -197,6 +184,12 @@ export function createUniverseFromResponse(
universe.schema.annotations.varByName = fromEntries( universe.schema.annotations.varByName = fromEntries(
universe.schema.annotations.var.map(v => [v.name, v]) 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; return universe;
} }
+3 -3
View File
@@ -260,7 +260,7 @@ function deduceDimensionType(attributes, fieldName) {
return dimensionType; 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 create and return a crossfilter with a dimension for every obs annotation
for which we have a supported type, *except* 'name' for which we have a supported type, *except* 'name'
@@ -283,8 +283,8 @@ export function createObsDimensions(crossfilter, world) {
return crossfilter.addDimension( return crossfilter.addDimension(
layoutDimensionName("XY"), layoutDimensionName("XY"),
"spatial", "spatial",
obsLayout.col("X").asArray(), obsLayout.col(XYdimNames[0]).asArray(),
obsLayout.col("Y").asArray() obsLayout.col(XYdimNames[1]).asArray()
); );
} }
@@ -115,6 +115,7 @@ class ScanpyEngine(CXGDriver):
"type": str(self.data.X.dtype), "type": str(self.data.X.dtype),
}, },
"annotations": {"obs": [], "var": []}, "annotations": {"obs": [], "var": []},
"layout": {"obs": []}
} }
for ax in Axis: for ax in Axis:
curr_axis = getattr(self.data, str(ax)) curr_axis = getattr(self.data, str(ax))
@@ -140,6 +141,14 @@ class ScanpyEngine(CXGDriver):
) )
self.schema["annotations"][ax].append(ann_schema) 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): def _load_data(self, data):
# as of AnnData 0.6.19, backed mode performs initial load fast, but at the # as of AnnData 0.6.19, backed mode performs initial load fast, but at the
# cost of significantly slower access to X data. # cost of significantly slower access to X data.
+9
View File
@@ -47,5 +47,14 @@
"type": "int32" "type": "int32"
} }
] ]
},
"layout": {
"obs": [
{
"name": "umap",
"type": "float32",
"dims": ["umap_0", "umap_1"]
}
]
} }
} }