From 2357d0c1b86dd106b042007528cc9717bae5cd61 Mon Sep 17 00:00:00 2001 From: Bruce Martin Date: Wed, 22 May 2019 13:21:33 -0700 Subject: [PATCH] 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 --- .../util/stateManager/sampleResponses.js | 4 + .../util/stateManager/universe.test.js | 4 +- .../__tests__/util/stateManager/world.test.js | 7 +- client/src/components/graph/graph.js | 76 +++++++++++++++++-- client/src/reducers/crossfilter.js | 30 +++++++- client/src/reducers/graphSelection.js | 3 +- client/src/reducers/index.js | 53 +++++++------ client/src/reducers/layoutChoice.js | 50 ++++++++++++ client/src/reducers/undoableConfig.js | 4 +- client/src/util/stateManager/universe.js | 31 +++----- client/src/util/stateManager/world.js | 6 +- server/app/scanpy_engine/scanpy_engine.py | 9 +++ server/test/schema.json | 9 +++ 13 files changed, 223 insertions(+), 63 deletions(-) create mode 100644 client/src/reducers/layoutChoice.js 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"] + } + ] } }