mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-05 03:48:12 +08:00
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:
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -47,5 +47,14 @@
|
||||
"type": "int32"
|
||||
}
|
||||
]
|
||||
},
|
||||
"layout": {
|
||||
"obs": [
|
||||
{
|
||||
"name": "umap",
|
||||
"type": "float32",
|
||||
"dims": ["umap_0", "umap_1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user