* menubar 1

* zoom switching

* centering, pixel perfect canvas

* remove dead args and code

* clipping

* remove log

* if

* connect props

* lint

* undo

* logo left, componetize

* graph back to full height

* shadow to top

* do not prematurely call event handlers during render

* change test to deal with async histogram creation

* left section padding

* lint

* adjust graph to account for top bar,

* lasso tests

* refine histogram tests

* remove testing (onlys)
This commit is contained in:
Colin Megill
2019-06-13 15:15:43 -07:00
committed by Charlotte Weaver
parent eac514e04d
commit 6aeefb0fe6
23 changed files with 977 additions and 972 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ export const datasets = {
lasso: [ lasso: [
{ {
"coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 }, "coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 },
count: "101" count: "71"
} }
], ],
categorical: [ categorical: [
@@ -92,7 +92,7 @@ export const datasets = {
}, },
lasso: { lasso: {
"coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 }, "coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 },
count: "46" count: "36"
} }
}, },
scatter: { scatter: {
+12 -5
View File
@@ -154,10 +154,13 @@ describe("gene entry", () => {
await utils.clickOn("section-bulk-add"); await utils.clickOn("section-bulk-add");
await utils.typeInto("input-bulk-add", testGenes.join(",")); await utils.typeInto("input-bulk-add", testGenes.join(","));
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
const userGeneHist = await cxgActions.getAllHistograms(
"histogram-user-gene" const allHistograms = await cxgActions.getAllHistograms(
"histogram-user-gene",
testGenes
); );
expect(userGeneHist).toEqual(expect.arrayContaining(testGenes)); expect(allHistograms).toEqual(expect.arrayContaining(testGenes));
expect(allHistograms.length).toEqual(testGenes.length);
}); });
}); });
@@ -176,10 +179,14 @@ describe("diffexp", () => {
} }
await cxgActions.cellSet(2); await cxgActions.cellSet(2);
await utils.clickOn("diffexp-button"); await utils.clickOn("diffexp-button");
const diffExpHists = await cxgActions.getAllHistograms("histogram-diffexp"); const allHistograms = await cxgActions.getAllHistograms(
expect(diffExpHists).toEqual( "histogram-diffexp",
data.diffexp["gene-results"]
);
expect(allHistograms).toEqual(
expect.arrayContaining(data.diffexp["gene-results"]) expect.arrayContaining(data.diffexp["gene-results"])
); );
expect(allHistograms.length).toEqual(data.diffexp["gene-results"].length);
}); });
}); });
+40 -19
View File
@@ -13,6 +13,26 @@ export const puppeteerUtils = puppeteerPage => ({
); );
}, },
async waitForAllByIds(testids, props = {}) {
await Promise.all(
testids.map(testid =>
puppeteerPage.waitForSelector(`[data-testid='${testid}']`)
)
);
},
async getAllByClass(testclass, props = {}) {
const elements = await puppeteerPage.$$eval(
`[data-testclass=${testclass}]`,
els => {
return els.map(el => {
return el.dataset.testid;
});
}
);
return elements;
},
async typeInto(testid, text) { async typeInto(testid, text) {
// only works for text without special characters // only works for text without special characters
await this.waitByID(testid); await this.waitByID(testid);
@@ -32,8 +52,8 @@ export const puppeteerUtils = puppeteerPage => ({
await puppeteerPage.waitFor(200); await puppeteerPage.waitFor(200);
// select all // select all
await puppeteerPage.click(selector, {clickCount: 3}) await puppeteerPage.click(selector, { clickCount: 3 });
await puppeteerPage.keyboard.type("Backspace") await puppeteerPage.keyboard.type("Backspace");
await puppeteerPage.type(selector, text); await puppeteerPage.type(selector, text);
}, },
@@ -77,20 +97,16 @@ export const cellxgeneActions = puppeteerPage => ({
await puppeteerPage.mouse.up(); await puppeteerPage.mouse.up();
}, },
async getAllHistograms(testclass) { async getAllHistograms(testclass, testids) {
await puppeteerUtils(puppeteerPage).waitByClass(testclass); const histTestIds = testids.map(tid => `histogram-${tid}`);
const histograms = await puppeteerPage.$$eval( // these load asynchronously, so we need to wait for each histogram individually
`[data-testclass=${testclass}]`, await puppeteerUtils(puppeteerPage).waitForAllByIds(histTestIds);
els => { const allHistograms = await puppeteerUtils(puppeteerPage).getAllByClass(
return els.map(el => { testclass
return el.dataset.testid.substring( );
"histogram_".length, return allHistograms.map(hist =>
el.dataset.testid.length hist.substr("histogram_".length, hist.length)
);
});
}
); );
return histograms;
}, },
async getAllCategoriesAndCounts(category) { async getAllCategoriesAndCounts(category) {
@@ -180,11 +196,16 @@ export const cellxgeneActions = puppeteerPage => ({
await page.waitFor(200); await page.waitFor(200);
}, },
async clip(min = 0, max = 100) { async clip(min = 0, max = 100) {
await puppeteerUtils(puppeteerPage).clickOn("visualization-settings"); await puppeteerUtils(puppeteerPage).clickOn("visualization-settings");
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto("clip-min-input", min); await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto("clip-max-input", max); "clip-min-input",
min
);
await puppeteerUtils(puppeteerPage).clearInputAndTypeInto(
"clip-max-input",
max
);
await puppeteerUtils(puppeteerPage).clickOn("clip-commit"); await puppeteerUtils(puppeteerPage).clickOn("clip-commit");
} }
}); });
+15 -8
View File
@@ -4,9 +4,11 @@ import Helmet from "react-helmet";
import { connect } from "react-redux"; import { connect } from "react-redux";
import Container from "./framework/container"; import Container from "./framework/container";
import LeftSideBar from "./leftsidebar"; import LeftSideBar from "./leftSidebar";
import Legend from "./continuousLegend"; import Legend from "./continuousLegend";
import Graph from "./graph/graph"; import Graph from "./graph/graph";
import MenuBar from "./menubar";
import actions from "../actions"; import actions from "../actions";
@connect(state => ({ @connect(state => ({
@@ -71,18 +73,23 @@ class App extends React.Component {
loading cellxgene loading cellxgene
</div> </div>
) : null} ) : null}
<div> {error ? (
{loading ? null : <LeftSideBar />}
<div <div
style={{ style={{
padding: 15, position: "fixed",
width: 1440 - 410 /* but responsive */, fontWeight: 500,
marginLeft: 350 /* but responsive */ top: window.innerHeight / 2,
left: window.innerWidth / 2 - 50
}} }}
> >
{loading ? null : <Graph key={graphRenderCounter} />} error loading
<Legend />
</div> </div>
) : null}
<div>
{loading ? null : <LeftSideBar />}
{loading ? null : <MenuBar />}
{loading ? null : <Graph key={graphRenderCounter} />}
<Legend />
</div> </div>
</Container> </Container>
); );
@@ -19,13 +19,6 @@ class Categories extends React.Component {
padding: globals.leftSidebarSectionPadding padding: globals.leftSidebarSectionPadding
}} }}
> >
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 4
})}
>
Categorical Metadata
</p>
{_.map(categoricalSelection, (catState, catName) => ( {_.map(categoricalSelection, (catState, catName) => (
<Category key={catName} metadataField={catName} /> <Category key={catName} metadataField={catName} />
))} ))}
@@ -48,7 +48,7 @@ class Continuous extends React.Component {
} }
/* initial value for iterator to simulate index, ranges is an object */ /* initial value for iterator to simulate index, ranges is an object */
let zebra = -1; let zebra = 0;
return ( return (
<div> <div>
@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import * as d3 from "d3"; import * as d3 from "d3";
import { interpolateViridis, interpolateCool } from "d3-scale-chromatic"; import { interpolateCool } from "d3-scale-chromatic";
// create continuous color legend // create continuous color legend
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f // http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
+1 -1
View File
@@ -4,7 +4,7 @@ import * as globals from "../../globals";
const Logo = props => { const Logo = props => {
const { size } = props; const { size } = props;
return ( return (
<svg width={size} height={size} viewBox={`0 0 48 48`} fill="none"> <svg width={size} height={size} viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" fill="white" /> <rect width="48" height="48" fill="white" />
<rect width="48" height="48" fill={globals.logoColor} /> <rect width="48" height="48" fill={globals.logoColor} />
<rect x="19" y="19" width="22" height="22" fill="white" /> <rect x="19" y="19" width="22" height="22" fill="white" />
@@ -1,101 +0,0 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
import { Button, AnchorButton, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import * as globals from "../../globals";
import actions from "../../actions";
import CellSetButton from "./cellSetButtons";
@connect(state => ({
differential: state.differential,
world: state.world,
crossfilter: state.crossfilter
}))
class Expression extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
computeDiffExp() {
const { dispatch, differential } = this.props;
if (differential.celllist1 && differential.celllist2) {
dispatch(
actions.requestDifferentialExpression(
differential.celllist1,
differential.celllist2
)
);
}
}
clearDifferentialExpression() {
const { dispatch, differential } = this.props;
dispatch({
type: "clear differential expression",
diffExp: differential.diffExp
});
dispatch({
type: "clear scatterplot"
});
}
render() {
const { differential } = this.props;
if (!differential) {
return null;
}
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
return (
<div
style={{
marginRight: 10,
marginBottom: 10,
paddingLeft: globals.leftSidebarSectionPadding
}}
>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
<Tooltip
content="Add two cells selections, see the top 15 differentially expressed genes between them"
position="bottom"
>
<AnchorButton
style={{ marginTop: 10 }}
disabled={!haveBothCellSets}
intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
fill
type="button"
onClick={this.computeDiffExp.bind(this)}
>
Compute Differential Expression
</AnchorButton>
</Tooltip>
) : null}
{differential.diffExp ? (
<Tooltip
content="Remove differentially expressed gene list and clear cell selections"
position="bottom"
>
<Button
type="button"
fill
style={{ marginTop: 10 }}
intent="warning"
onClick={this.clearDifferentialExpression.bind(this)}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</div>
);
}
}
export default Expression;
+4 -26
View File
@@ -21,7 +21,6 @@ import {
postUserErrorToast, postUserErrorToast,
keepAroundErrorToast keepAroundErrorToast
} from "../framework/toasters"; } from "../framework/toasters";
import ExpressionButtons from "./expressionButtons";
const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => { const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
if (!modifiers.matchesPredicate) { if (!modifiers.matchesPredicate) {
@@ -180,19 +179,7 @@ class GeneExpression extends React.Component {
return ( return (
<div> <div>
<div <div>
style={{
marginTop: 30
}}
>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
paddingLeft: globals.leftSidebarSectionPadding,
margin: 0
})}
>
Selected Genes
</p>
<div <div
style={{ style={{
padding: globals.leftSidebarSectionPadding padding: globals.leftSidebarSectionPadding
@@ -208,7 +195,7 @@ class GeneExpression extends React.Component {
this.setState({ tab: "autosuggest" }); this.setState({ tab: "autosuggest" });
}} }}
> >
Autosuggest Autosuggest genes
</Button> </Button>
<Button <Button
active={tab === "bulkadd"} active={tab === "bulkadd"}
@@ -260,7 +247,7 @@ class GeneExpression extends React.Component {
data-testid={"add-gene"} data-testid={"add-gene"}
loading={userDefinedGenesLoading} loading={userDefinedGenesLoading}
> >
Add Add gene
</Button> </Button>
</ControlGroup> </ControlGroup>
) : null} ) : null}
@@ -291,7 +278,7 @@ class GeneExpression extends React.Component {
onClick={this.handleBulkAddClick.bind(this)} onClick={this.handleBulkAddClick.bind(this)}
loading={userDefinedGenesLoading} loading={userDefinedGenesLoading}
> >
Add Add genes
</Button> </Button>
</ControlGroup> </ControlGroup>
</FormGroup> </FormGroup>
@@ -318,15 +305,6 @@ class GeneExpression extends React.Component {
: null} : null}
</div> </div>
<div> <div>
<p
style={Object.assign({}, globals.leftSidebarSectionHeading, {
marginTop: 40,
paddingLeft: globals.leftSidebarSectionPadding
})}
>
Differentially Expressed Genes
</p>
<ExpressionButtons />
{differential.diffExp {differential.diffExp
? _.map(differential.diffExp, (value, index) => { ? _.map(differential.diffExp, (value, index) => {
const name = world.varAnnotations.at(value[0], varIndexName); const name = world.varAnnotations.at(value[0], varIndexName);
+40 -579
View File
@@ -3,82 +3,28 @@ import React from "react";
import * as d3 from "d3"; import * as d3 from "d3";
import { connect } from "react-redux"; import { connect } from "react-redux";
import mat4 from "gl-mat4"; import mat4 from "gl-mat4";
import vec3 from "gl-vec3";
import _regl from "regl"; import _regl from "regl";
import memoize from "memoize-one"; import memoize from "memoize-one";
import {
Button,
AnchorButton,
Tooltip,
Popover,
Menu,
MenuItem,
Position,
NumericInput,
Icon,
RadioGroup,
Radio
} from "@blueprintjs/core";
import * as globals from "../../globals"; import * as globals from "../../globals";
import setupSVGandBrushElements from "./setupSVGandBrush"; import setupSVGandBrushElements from "./setupSVGandBrush";
import actions from "../../actions";
import _camera from "../../util/camera"; import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl"; import _drawPoints from "./drawPointsRegl";
import scaleLinear from "../../util/scaleLinear"; import scaleLinear from "../../util/scaleLinear";
import { World } from "../../util/stateManager";
/* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */ /* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */
@connect(state => ({ @connect(state => ({
world: state.world, world: state.world,
universe: state.universe,
crossfilter: state.crossfilter, crossfilter: state.crossfilter,
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
responsive: state.responsive, responsive: state.responsive,
colorRGB: state.colors.rgb, colorRGB: state.colors.rgb,
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
resettingInterface: state.controls.resettingInterface,
userDefinedGenes: state.controls.userDefinedGenes,
diffexpGenes: state.controls.diffexpGenes,
colorAccessor: state.colors.colorAccessor,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
celllist1: state.differential.celllist1,
celllist2: state.differential.celllist2,
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase
undoDisabled: state["@@undoable/past"].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 layoutChoice: state.layoutChoice,
graphInteractionMode: state.controls.graphInteractionMode
})) }))
class Graph extends React.Component { class Graph extends React.Component {
static isValidDigitKeyEvent(e) {
/*
Return true if this event is necessary to enter a percent number input.
Return false if not.
Returns true for events with keys: backspace, control, alt, meta, [0-9],
or events that don't have a key.
*/
if (e.key === null) return true;
if (e.ctrlKey || e.altKey || e.metaKey) return true;
// concept borrowed from blueprint's numericInputUtils:
// keys that print a single character when pressed have a `key` name of
// length 1. every other key has a longer `key` name (e.g. "Backspace",
// "ArrowUp", "Shift"). since none of those keys can print a character
// to the field--and since they may have important native behaviors
// beyond printing a character--we don't want to disable their effects.
const isSingleCharKey = e.key.length === 1;
if (!isSingleCharKey) return true;
const key = e.key.charCodeAt(0) - 48; /* "0" */
return key >= 0 && key <= 9;
}
computePointPositions = memoize((X, Y, scaleX, scaleY) => { computePointPositions = memoize((X, Y, scaleX, scaleY) => {
/* /*
compute webgl coordinate buffer for each point compute webgl coordinate buffer for each point
@@ -115,7 +61,6 @@ class Graph extends React.Component {
super(props); super(props);
this.count = 0; this.count = 0;
this.graphPaddingTop = 0; this.graphPaddingTop = 0;
this.graphPaddingBottom = 45;
this.graphPaddingRight = globals.leftSidebarWidth; this.graphPaddingRight = globals.leftSidebarWidth;
this.renderCache = { this.renderCache = {
X: null, X: null,
@@ -127,9 +72,7 @@ class Graph extends React.Component {
this.state = { this.state = {
svg: null, svg: null,
tool: null, tool: null,
container: null, container: null
mode: "select",
pendingClipPercentiles: null
}; };
} }
@@ -146,10 +89,17 @@ class Graph extends React.Component {
const sizeBuffer = regl.buffer(); const sizeBuffer = regl.buffer();
// preallocate coordinate system transformation between data and gl // preallocate coordinate system transformation between data and gl
const fractionToUse = 0.98; // fraction of dimension to use const fractionToUse = 0.93; // fraction of dimension to use
const shiftForMenuBar = 0.05;
const transform = { const transform = {
glScaleX: scaleLinear([0, 1], [-1 * fractionToUse, 1 * fractionToUse]), glScaleX: scaleLinear([0, 1], [-1 * fractionToUse, 1 * fractionToUse]),
glScaleY: scaleLinear([0, 1], [1 * fractionToUse, -1 * fractionToUse]) glScaleY: scaleLinear(
[0, 1],
[
(1 + shiftForMenuBar) * fractionToUse,
(-1 + shiftForMenuBar) * fractionToUse
]
)
}; };
/* first time, but this duplicates above function, should be possile to avoid this */ /* first time, but this duplicates above function, should be possile to avoid this */
@@ -179,7 +129,7 @@ class Graph extends React.Component {
}); });
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps) {
const { renderCache } = this; const { renderCache } = this;
const { const {
world, world,
@@ -188,14 +138,30 @@ class Graph extends React.Component {
responsive, responsive,
selectionTool, selectionTool,
currentSelection, currentSelection,
layoutChoice layoutChoice,
graphInteractionMode
} = this.props; } = this.props;
const { reglRender, mode, regl, svg } = this.state; const { reglRender, regl, svg } = this.state;
let stateChanges = {}; let stateChanges = {};
if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") { if (reglRender) {
reglRender.cancel(); if (
this.reglRenderState = "paused"; // If it IS RENDERING and it is NOT IN ZOOM mode, stop rendering.
this.reglRenderState === "rendering" &&
graphInteractionMode !== "zoom"
) {
reglRender.cancel();
this.reglRenderState = "paused";
}
if (
// If it is NOT RENDERING and it IS IN ZOOM mode, start rendering
this.reglRenderState !== "rendering" &&
graphInteractionMode === "zoom"
) {
this.restartReglLoop();
this.reglRenderState = "rendering";
}
} }
if (regl && world) { if (regl && world) {
@@ -293,7 +259,7 @@ class Graph extends React.Component {
*/ */
if ( if (
currentSelection !== prevProps.currentSelection || currentSelection !== prevProps.currentSelection ||
mode !== prevState.mode || graphInteractionMode !== prevProps.graphInteractionMode ||
stateChanges.svg stateChanges.svg
) { ) {
const { tool, container } = this.state; const { tool, container } = this.state;
@@ -308,165 +274,6 @@ class Graph extends React.Component {
} }
} }
isResetDisabled = () => {
/*
Reset should be disabled when all of the following are true:
* nothing is selected in the crossfilter
* world EQ universe
* nothing is colored by
* there are no userDefinedGenes or diffexpGenes displayed
* scatterplot is not displayed
* nothing in cellset1 or cellset2
* clip percentiles are [0,100]
*/
const {
crossfilter,
world,
universe,
userDefinedGenes,
diffexpGenes,
colorAccessor,
scatterplotXXaccessor,
scatterplotYYaccessor,
celllist1,
celllist2,
clipPercentileMin,
clipPercentileMax
} = this.props;
if (!crossfilter || !world || !universe) {
return false;
}
const nothingSelected = crossfilter.countSelected() === crossfilter.size();
const nothingColoredBy = !colorAccessor;
const noGenes = userDefinedGenes.length === 0 && diffexpGenes.length === 0;
const scatterNotDpl = !scatterplotXXaccessor || !scatterplotYYaccessor;
const nothingInCellsets = !celllist1 && !celllist2;
return (
nothingSelected &&
World.worldEqUniverse(world, universe) &&
nothingColoredBy &&
noGenes &&
scatterNotDpl &&
nothingInCellsets &&
clipPercentileMax === 100 &&
clipPercentileMin === 0
);
};
resetInterface = () => {
const { dispatch } = this.props;
dispatch({
type: "interface reset started"
});
dispatch(actions.resetInterface());
};
isClipDisabled = () => {
/*
return true if clip button should be disabled.
*/
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
const { world } = this.props;
const currentClipMin = 100 * world?.clipQuantiles?.min;
const currentClipMax = 100 * world?.clipQuantiles?.max;
// if you change this test, be careful with logic around
// comparisons between undefined / NaN handling.
const isDisabled =
!(clipPercentileMin < clipPercentileMax) ||
(clipPercentileMin === currentClipMin &&
clipPercentileMax === currentClipMax);
return isDisabled;
};
handleClipOnKeyPress = e => {
/*
allow only numbers, plus other critical keys which
may be required to make a number
*/
if (!Graph.isValidDigitKeyEvent(e)) {
e.preventDefault();
}
};
handleClipPercentileMinValueChange = v => {
/*
Ignore anything that isn't a legit number
*/
if (!Number.isFinite(v)) return;
const { pendingClipPercentiles } = this.state;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
/*
clamp to [0, currentClipPercentileMax]
*/
if (v <= 0) v = 0;
if (v > 100) v = 100;
const clipPercentileMin = Math.round(v); // paranoia
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipPercentileMaxValueChange = v => {
/*
Ignore anything that isn't a legit number
*/
if (!Number.isFinite(v)) return;
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
/*
clamp to [0, 100]
*/
if (v < 0) v = 0;
if (v > 100) v = 100;
const clipPercentileMax = Math.round(v); // paranoia
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipCommit = () => {
const { dispatch } = this.props;
const { pendingClipPercentiles } = this.state;
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
const min = clipPercentileMin / 100;
const max = clipPercentileMax / 100;
dispatch({
type: "set clip quantiles",
clipQuantiles: { min, max }
});
};
handleClipOpening = () => {
const { clipPercentileMin, clipPercentileMax } = this.props;
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipClosing = () => {
this.setState({ pendingClipPercentiles: null });
};
handleLayoutChoiceChange = e => {
const { dispatch } = this.props;
dispatch({
type: "set layout choice",
layoutChoice: e.currentTarget.value
});
};
brushToolUpdate(tool, container) { brushToolUpdate(tool, container) {
/* /*
this is called from componentDidUpdate(), so be very careful using this is called from componentDidUpdate(), so be very careful using
@@ -509,7 +316,7 @@ class Graph extends React.Component {
} }
} }
lassoToolUpdate(tool, container) { lassoToolUpdate(tool) {
/* /*
this is called from componentDidUpdate(), so be very careful using this is called from componentDidUpdate(), so be very careful using
anything from this.state, which may be updated asynchronously. anything from this.state, which may be updated asynchronously.
@@ -636,7 +443,7 @@ class Graph extends React.Component {
const scale = aspect < 1 ? 1 / aspect : 1; const scale = aspect < 1 ? 1 / aspect : 1;
// compute inverse view matrix // compute inverse view matrix
let inverse = mat4.invert([], camera.view()); const inverse = mat4.invert([], camera.view());
// variable names are choosen to reflect inverse of those used // variable names are choosen to reflect inverse of those used
// in mapScreenToPoint(). // in mapScreenToPoint().
@@ -769,367 +576,21 @@ class Graph extends React.Component {
} }
render() { render() {
const { const { responsive, graphInteractionMode } = this.props;
dispatch,
responsive,
crossfilter,
resettingInterface,
libraryVersions,
undoDisabled,
redoDisabled,
selectionTool,
clipPercentileMin,
clipPercentileMax,
layoutChoice
} = this.props;
const { mode, pendingClipPercentiles } = this.state;
const clipMin =
pendingClipPercentiles?.clipPercentileMin ?? clipPercentileMin;
const clipMax =
pendingClipPercentiles?.clipPercentileMax ?? clipPercentileMax;
const activeClipClass =
clipPercentileMin > 0 || clipPercentileMax < 100
? " bp3-intent-warning"
: "";
// constants used to create selection tool button
let selectionTooltip;
let selectionButtonClass;
if (selectionTool === "brush") {
selectionTooltip = "Brush selection";
selectionButtonClass = "bp3-icon-select";
} else {
selectionTooltip = "Lasso selection";
selectionButtonClass = "bp3-icon-polygon-filter";
}
return ( return (
<div id="graphWrapper"> <div id="graphWrapper">
<div
style={{
position: "fixed",
right: 0,
top: 0
}}
>
<div
style={{
padding: 10,
display: "flex",
justifyContent: "flex-end",
alignItems: "baseline"
}}
>
<Tooltip
content="Show only metadata and cells which are currently selected"
position="left"
>
<AnchorButton
type="button"
data-testid="subset-button"
disabled={
crossfilter &&
(crossfilter.countSelected() === 0 ||
crossfilter.countSelected() === crossfilter.size())
}
style={{ marginRight: 10 }}
onClick={() => {
dispatch(actions.regraph());
dispatch({ type: "increment graph render counter" });
}}
>
subset to current selection
</AnchorButton>
</Tooltip>
<Tooltip
content="Reset cellxgene, clearing all selections"
position="left"
>
<AnchorButton
disabled={this.isResetDisabled()}
type="button"
loading={resettingInterface}
intent="warning"
style={{ marginRight: 10 }}
onClick={this.resetInterface}
data-testid="reset"
data-testclass={`resetting-${resettingInterface}`}
>
reset
</AnchorButton>
</Tooltip>
<div className="bp3-button-group">
<Tooltip content={selectionTooltip} position="left">
<Button
type="button"
data-testid="mode-lasso"
className={`bp3-button ${selectionButtonClass}`}
active={mode === "select"}
onClick={() => {
this.setState({ mode: "select" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Pan and zoom" position="left">
<Button
type="button"
data-testid="mode-pan-zoom"
className="bp3-button bp3-icon-zoom-in"
active={mode === "zoom"}
onClick={() => {
this.restartReglLoop();
this.setState({ mode: "zoom" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
</div>
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Tooltip content="Undo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-undo"
disabled={undoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/undo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Redo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-redo"
disabled={redoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/redo" });
}}
style={{
cursor: "pointer"
}}
/>
</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={{
marginLeft: 10
}}
>
<Popover
target={
<Button
type="button"
data-testid="visualization-settings"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
onOpening={this.handleClipOpening}
onClosing={this.handleClipClosing}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<div>Clip all continuous values to percentile range</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingTop: 5,
paddingBottom: 5
}}
>
<NumericInput
style={{ width: 50 }}
data-testid={"clip-min-input"}
onValueChange={this.handleClipPercentileMinValueChange}
onKeyPress={this.handleClipOnKeyPress}
value={clipMin}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon
icon="percentage"
intent="primary"
iconSize={14}
/>
</div>
}
/>
<span style={{ marginRight: 5, marginLeft: 5 }}> - </span>
<NumericInput
style={{ width: 50 }}
data-testid={"clip-max-input"}
onValueChange={this.handleClipPercentileMaxValueChange}
onKeyPress={this.handleClipOnKeyPress}
value={clipMax}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon
icon="percentage"
intent="primary"
iconSize={14}
/>
</div>
}
/>
<Button
type="button"
data-testid="clip-commit"
className="bp3-button"
disabled={this.isClipDisabled()}
style={{
cursor: "pointer",
marginRight: 5,
marginLeft: 5
}}
onClick={this.handleClipCommit}
>
Clip
</Button>
</div>
</div>
}
/>
</div>
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Popover
content={
<Menu>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/faq.html"
target="_blank"
icon="help"
text="FAQ"
/>
<MenuItem
href="https://join-cellxgene-users.herokuapp.com/"
target="_blank"
icon="chat"
text="Chat"
/>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/"
target="_blank"
icon="book"
text="Docs"
/>
<MenuItem
href="https://github.com/chanzuckerberg/cellxgene"
target="_blank"
icon="git-branch"
text="Github"
/>
<MenuItem
target="_blank"
text={`cellxgene v${
libraryVersions && libraryVersions.cellxgene
? libraryVersions.cellxgene
: null
}`}
/>
<MenuItem text="MIT License" />
</Menu>
}
position={Position.BOTTOM_RIGHT}
>
<Button
type="button"
className="bp3-button bp3-icon-info-sign"
style={{
cursor: "pointer"
}}
/>
</Popover>
</div>
</div>
</div>
<div <div
style={{ style={{
zIndex: -9999, zIndex: -9999,
position: "fixed", position: "fixed",
top: 0, top: this.graphPaddingTop,
right: 0 right: 0
}} }}
> >
<div <div
style={{ style={{
display: mode === "select" ? "inherit" : "none" display: graphInteractionMode === "select" ? "inherit" : "none"
}} }}
id="graphAttachPoint" id="graphAttachPoint"
/> />
@@ -0,0 +1,61 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import Categorical from "../categorical/categorical";
import Continuous from "../continuous/continuous";
import GeneExpression from "../geneExpression";
import * as globals from "../../globals";
import DynamicScatterplot from "../scatterplot/scatterplot";
import TopLeftLogoAndTitle from "./topLeftLogoAndTitle";
@connect(state => ({
responsive: state.responsive,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class LeftSideBar extends React.Component {
render() {
const {
responsive,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
/*
this magic number should be made less fragile,
if cellxgene logo or tabs change, this must as well
*/
const logoRelatedPadding = 50;
return (
<div
style={{
position: "fixed",
backgroundColor: "white",
/* x y blur spread color */
boxShadow: "-3px 0px 6px 2px rgba(153,153,153,0.4)"
}}
>
<TopLeftLogoAndTitle />
<div
style={{
height: responsive.height - logoRelatedPadding,
marginTop: logoRelatedPadding,
width: globals.leftSidebarWidth,
overflowY: "auto",
overflowX: "hidden"
}}
>
<Categorical />
<GeneExpression />
<Continuous />
</div>
{scatterplotXXaccessor && scatterplotYYaccessor ? (
<DynamicScatterplot />
) : null}
</div>
);
}
}
export default LeftSideBar;
@@ -0,0 +1,71 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import * as globals from "../../globals";
import Logo from "../framework/logo";
@connect(state => ({
responsive: state.responsive,
datasetTitle: state.config?.displayNames?.dataset ?? "",
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class LeftSideBar extends React.Component {
render() {
const { datasetTitle } = this.props;
const paddingToAvoidScrollBar = 15;
return (
<div
style={{
paddingLeft: 8,
paddingTop: 8,
width: globals.leftSidebarWidth - paddingToAvoidScrollBar,
position: "absolute",
backgroundColor: "white",
zIndex: 8888
/* x y blur spread color */
// boxShadow: "-5px -1px 4px 2px rgba(225,225,225,0.4)"
}}
>
<Logo size={30} />
<span
style={{
fontSize: 28,
position: "relative",
top: -6,
fontWeight: "bold",
marginLeft: 5,
color: globals.logoColor,
userSelect: "none"
}}
>
cell<span
style={{
position: "relative",
top: 1,
fontWeight: 300,
fontSize: 24
}}
>
×
</span>gene
</span>
<span
data-testid="header"
style={{
fontSize: 14,
position: "relative",
marginLeft: 7,
top: -8
}}
>
{datasetTitle}
</span>
</div>
);
}
}
export default LeftSideBar;
-105
View File
@@ -1,105 +0,0 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import Categorical from "./categorical/categorical";
import Continuous from "./continuous/continuous";
import GeneExpression from "./geneExpression";
import * as globals from "../globals";
import DynamicScatterplot from "./scatterplot/scatterplot";
import Logo from "./framework/logo.js";
@connect(state => ({
responsive: state.responsive,
datasetTitle: state.config?.displayNames?.dataset,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor
}))
class LeftSideBar extends React.Component {
render() {
const {
responsive,
datasetTitle,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
/*
this magic number should be made less fragile,
if cellxgene logo or tabs change, this must as well
*/
const metadataSectionPadding = 0;
return (
<div
style={{
position: "fixed",
backgroundColor: "white",
/* x y blur spread color */
boxShadow: "1px 0px 6px 2px rgba(153,153,153,0.4)"
}}
>
<p
style={{
position: "fixed",
top: globals.cellxgeneTitleTopPadding,
left: globals.leftSidebarWidth + globals.cellxgeneTitleLeftPadding,
margin: 0
}}
>
<Logo size={32} />
<span
style={{
fontSize: 28,
position: "relative",
top: -4,
fontWeight: "bold",
marginLeft: 5,
color: globals.logoColor,
userSelect: "none"
}}
>
cell<span
style={{
position: "relative",
top: 1,
fontWeight: 300,
fontSize: 24
}}
>
×
</span>gene
</span>
<span
data-testid="header"
style={{
fontSize: 16,
display: "block",
position: "relative",
marginTop: 10,
top: -4
}}
>
{datasetTitle}
</span>
</p>
<div
style={{
height: responsive.height - metadataSectionPadding,
width: globals.leftSidebarWidth,
overflowY: "auto",
overflowX: "hidden"
}}
>
<Categorical />
<GeneExpression />
<Continuous />
</div>
{scatterplotXXaccessor && scatterplotYYaccessor ? (
<DynamicScatterplot />
) : null}
</div>
);
}
}
export default LeftSideBar;
@@ -32,7 +32,7 @@ class CellSetButton extends React.Component {
render() { render() {
const { differential, eitherCellSetOneOrTwo } = this.props; const { differential, eitherCellSetOneOrTwo } = this.props;
const cellListName = `celllist${eitherCellSetOneOrTwo}`; const cellListName = `celllist${eitherCellSetOneOrTwo}`;
let cells_selected = differential[cellListName] const cellsSelected = differential[cellListName]
? differential[cellListName].length ? differential[cellListName].length
: 0; : 0;
return ( return (
@@ -41,7 +41,6 @@ class CellSetButton extends React.Component {
position="top" position="top"
> >
<AnchorButton <AnchorButton
style={{ marginRight: 10 }}
type="button" type="button"
disabled={differential.diffExp} disabled={differential.diffExp}
onClick={this.set.bind(this)} onClick={this.set.bind(this)}
@@ -50,7 +49,7 @@ class CellSetButton extends React.Component {
{eitherCellSetOneOrTwo} {eitherCellSetOneOrTwo}
{": "} {": "}
<span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}> <span data-testid={`cellset-count-${eitherCellSetOneOrTwo}`}>
{cells_selected} {cellsSelected}
</span> </span>
{" cells"} {" cells"}
</AnchorButton> </AnchorButton>
+130
View File
@@ -0,0 +1,130 @@
// jshint esversion: 6
import React from "react";
import {
Position,
Button,
Popover,
NumericInput,
Icon
} from "@blueprintjs/core";
function Clip(props) {
const {
pendingClipPercentiles,
clipPercentileMin,
clipPercentileMax,
handleClipOpening,
handleClipClosing,
handleClipCommit,
isClipDisabled,
handleClipOnKeyPress,
handleClipPercentileMaxValueChange,
handleClipPercentileMinValueChange
} = props;
const clipMin =
pendingClipPercentiles?.clipPercentileMin ?? clipPercentileMin;
const clipMax =
pendingClipPercentiles?.clipPercentileMax ?? clipPercentileMax;
const activeClipClass =
clipPercentileMin > 0 || clipPercentileMax < 100
? " bp3-intent-warning"
: "";
return (
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Popover
target={
<Button
type="button"
data-testid="visualization-settings"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{
cursor: "pointer"
}}
/>
}
position={Position.BOTTOM_RIGHT}
onOpening={handleClipOpening}
onClosing={handleClipClosing}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<div>Clip all continuous values to percentile range</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingTop: 5,
paddingBottom: 5
}}
>
<NumericInput
style={{ width: 50 }}
data-testid="clip-min-input"
onValueChange={handleClipPercentileMinValueChange}
onKeyPress={handleClipOnKeyPress}
value={clipMin}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon icon="percentage" intent="primary" iconSize={14} />
</div>
}
/>
<span style={{ marginRight: 5, marginLeft: 5 }}> - </span>
<NumericInput
style={{ width: 50 }}
data-testid="clip-max-input"
onValueChange={handleClipPercentileMaxValueChange}
onKeyPress={handleClipOnKeyPress}
value={clipMax}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon icon="percentage" intent="primary" iconSize={14} />
</div>
}
/>
<Button
type="button"
data-testid="clip-commit"
className="bp3-button"
disabled={isClipDisabled()}
style={{
cursor: "pointer",
marginRight: 5,
marginLeft: 5
}}
onClick={handleClipCommit}
>
Clip
</Button>
</div>
</div>
}
/>
</div>
);
}
export default Clip;
+464
View File
@@ -0,0 +1,464 @@
// jshint esversion: 6
import React from "react";
import { connect } from "react-redux";
import {
Button,
AnchorButton,
Tooltip,
Popover,
Position,
RadioGroup,
Radio
} from "@blueprintjs/core";
import { World } from "../../util/stateManager";
import actions from "../../actions";
import CellSetButton from "./cellSetButtons";
import InformationMenu from "./infoMenu";
import UndoRedoReset from "./undoRedoReset";
import Clip from "./clip";
@connect(state => ({
universe: state.universe,
world: state.world,
loading: state.controls.loading,
crossfilter: state.crossfilter,
differential: state.differential,
resettingInterface: state.controls.resettingInterface,
layoutChoice: state.layoutChoice,
graphInteractionMode: state.controls.graphInteractionMode,
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
userDefinedGenes: state.controls.userDefinedGenes,
diffexpGenes: state.controls.diffexpGenes,
colorAccessor: state.colors.colorAccessor,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
celllist1: state.differential.celllist1,
celllist2: state.differential.celllist2,
libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase
undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0
}))
class MenuBar extends React.Component {
static isValidDigitKeyEvent(e) {
/*
Return true if this event is necessary to enter a percent number input.
Return false if not.
Returns true for events with keys: backspace, control, alt, meta, [0-9],
or events that don't have a key.
*/
if (e.key === null) return true;
if (e.ctrlKey || e.altKey || e.metaKey) return true;
// concept borrowed from blueprint's numericInputUtils:
// keys that print a single character when pressed have a `key` name of
// length 1. every other key has a longer `key` name (e.g. "Backspace",
// "ArrowUp", "Shift"). since none of those keys can print a character
// to the field--and since they may have important native behaviors
// beyond printing a character--we don't want to disable their effects.
const isSingleCharKey = e.key.length === 1;
if (!isSingleCharKey) return true;
const key = e.key.charCodeAt(0) - 48; /* "0" */
return key >= 0 && key <= 9;
}
constructor(props) {
super(props);
this.state = {
pendingClipPercentiles: null
};
}
isClipDisabled = () => {
/*
return true if clip button should be disabled.
*/
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
const { world } = this.props;
const currentClipMin = 100 * world?.clipQuantiles?.min;
const currentClipMax = 100 * world?.clipQuantiles?.max;
// if you change this test, be careful with logic around
// comparisons between undefined / NaN handling.
const isDisabled =
!(clipPercentileMin < clipPercentileMax) ||
(clipPercentileMin === currentClipMin &&
clipPercentileMax === currentClipMax);
return isDisabled;
};
isResetDisabled = () => {
/*
Reset should be disabled when all of the following are true:
* nothing is selected in the crossfilter
* world EQ universe
* nothing is colored by
* there are no userDefinedGenes or diffexpGenes displayed
* scatterplot is not displayed
* nothing in cellset1 or cellset2
* clip percentiles are [0,100]
*/
const {
crossfilter,
world,
universe,
userDefinedGenes,
diffexpGenes,
colorAccessor,
scatterplotXXaccessor,
scatterplotYYaccessor,
celllist1,
celllist2,
clipPercentileMin,
clipPercentileMax
} = this.props;
if (!crossfilter || !world || !universe) {
return false;
}
const nothingSelected = crossfilter.countSelected() === crossfilter.size();
const nothingColoredBy = !colorAccessor;
const noGenes = userDefinedGenes.length === 0 && diffexpGenes.length === 0;
const scatterNotDpl = !scatterplotXXaccessor || !scatterplotYYaccessor;
const nothingInCellsets = !celllist1 && !celllist2;
return (
nothingSelected &&
World.worldEqUniverse(world, universe) &&
nothingColoredBy &&
noGenes &&
scatterNotDpl &&
nothingInCellsets &&
clipPercentileMax === 100 &&
clipPercentileMin === 0
);
};
resetInterface = () => {
const { dispatch } = this.props;
dispatch({
type: "interface reset started"
});
dispatch(actions.resetInterface());
};
handleClipOnKeyPress = e => {
/*
allow only numbers, plus other critical keys which
may be required to make a number
*/
if (!MenuBar.isValidDigitKeyEvent(e)) {
e.preventDefault();
}
};
handleClipPercentileMinValueChange = v => {
/*
Ignore anything that isn't a legit number
*/
if (!Number.isFinite(v)) return;
const { pendingClipPercentiles } = this.state;
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
/*
clamp to [0, currentClipPercentileMax]
*/
if (v <= 0) v = 0;
if (v > 100) v = 100;
const clipPercentileMin = Math.round(v); // paranoia
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipPercentileMaxValueChange = v => {
/*
Ignore anything that isn't a legit number
*/
if (!Number.isFinite(v)) return;
const { pendingClipPercentiles } = this.state;
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
/*
clamp to [0, 100]
*/
if (v < 0) v = 0;
if (v > 100) v = 100;
const clipPercentileMax = Math.round(v); // paranoia
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipCommit = () => {
const { dispatch } = this.props;
const { pendingClipPercentiles } = this.state;
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
const min = clipPercentileMin / 100;
const max = clipPercentileMax / 100;
dispatch({
type: "set clip quantiles",
clipQuantiles: { min, max }
});
};
handleClipOpening = () => {
const { clipPercentileMin, clipPercentileMax } = this.props;
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax }
});
};
handleClipClosing = () => {
this.setState({ pendingClipPercentiles: null });
};
handleLayoutChoiceChange = e => {
const { dispatch } = this.props;
dispatch({
type: "set layout choice",
layoutChoice: e.currentTarget.value
});
};
computeDiffExp = () => {
const { dispatch, differential } = this.props;
if (differential.celllist1 && differential.celllist2) {
dispatch(
actions.requestDifferentialExpression(
differential.celllist1,
differential.celllist2
)
);
}
};
clearDifferentialExpression = () => {
const { dispatch, differential } = this.props;
dispatch({
type: "clear differential expression",
diffExp: differential.diffExp
});
dispatch({
type: "clear scatterplot"
});
};
render() {
const {
dispatch,
differential,
crossfilter,
resettingInterface,
libraryVersions,
undoDisabled,
redoDisabled,
selectionTool,
clipPercentileMin,
clipPercentileMax,
layoutChoice,
graphInteractionMode
} = this.props;
const { pendingClipPercentiles } = this.state;
const haveBothCellSets =
!!differential.celllist1 && !!differential.celllist2;
// constants used to create selection tool button
let selectionTooltip;
let selectionButtonClass;
if (selectionTool === "brush") {
selectionTooltip = "Brush selection";
selectionButtonClass = "bp3-icon-select";
} else {
selectionTooltip = "Lasso selection";
selectionButtonClass = "bp3-icon-polygon-filter";
}
return (
<div
style={{
position: "fixed",
right: 8,
top: 8
}}
>
<div className="bp3-button-group" style={{ marginRight: 10 }}>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
<Tooltip
content="Add two cells selections, see the top 15 differentially expressed genes between them"
position="bottom"
>
<AnchorButton
disabled={!haveBothCellSets}
intent="primary"
data-testid="diffexp-button"
loading={differential.loading}
icon="left-join"
fill
type="button"
onClick={this.computeDiffExp}
>
Compute Differential Expression
</AnchorButton>
</Tooltip>
) : null}
{differential.diffExp ? (
<Tooltip
content="Remove differentially expressed gene list and clear cell selections"
position="bottom"
>
<Button
type="button"
fill
intent="warning"
onClick={this.clearDifferentialExpression}
>
Clear Differential Expression
</Button>
</Tooltip>
) : null}
</div>
<Tooltip
content="Show only metadata and cells which are currently selected"
position="left"
>
<AnchorButton
type="button"
data-testid="subset-button"
disabled={
crossfilter &&
(crossfilter.countSelected() === 0 ||
crossfilter.countSelected() === crossfilter.size())
}
style={{
marginRight: 10
}}
onClick={() => {
dispatch(actions.regraph());
dispatch({ type: "increment graph render counter" });
}}
>
subset to current selection
</AnchorButton>
</Tooltip>
<div className="bp3-button-group">
<Tooltip content={selectionTooltip} position="left">
<Button
type="button"
data-testid="mode-lasso"
className={`bp3-button ${selectionButtonClass}`}
active={graphInteractionMode === "select"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "select"
});
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Pan and zoom" position="left">
<Button
type="button"
data-testid="mode-pan-zoom"
className="bp3-button bp3-icon-zoom-in"
active={graphInteractionMode === "zoom"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "zoom"
});
}}
style={{
cursor: "pointer"
}}
/>
</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>
<Clip
pendingClipPercentiles={pendingClipPercentiles}
clipPercentileMin={clipPercentileMin}
clipPercentileMax={clipPercentileMax}
handleClipOpening={this.handleClipOpening}
handleClipClosing={this.handleClipClosing}
handleClipCommit={this.handleClipCommit}
isClipDisabled={this.isClipDisabled}
handleClipOnKeyPress={this.handleClipOnKeyPress}
handleClipPercentileMaxValueChange={
this.handleClipPercentileMaxValueChange
}
handleClipPercentileMinValueChange={
this.handleClipPercentileMinValueChange
}
/>
<UndoRedoReset
dispatch={dispatch}
isResetDisabled={this.isResetDisabled}
resetInterface={this.resetInterface}
resettingInterface={resettingInterface}
undoDisabled={undoDisabled}
redoDisabled={redoDisabled}
/>
<InformationMenu libraryVersions={libraryVersions} />
</div>
);
}
}
export default MenuBar;
+61
View File
@@ -0,0 +1,61 @@
// jshint esversion: 6
import React from "react";
import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
function InformationMenu(props) {
const { libraryVersions } = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Popover
content={
<Menu>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/faq.html"
target="_blank"
icon="help"
text="FAQ"
/>
<MenuItem
href="https://join-cellxgene-users.herokuapp.com/"
target="_blank"
icon="chat"
text="Chat"
/>
<MenuItem
href="https://chanzuckerberg.github.io/cellxgene/"
target="_blank"
icon="book"
text="Docs"
/>
<MenuItem
href="https://github.com/chanzuckerberg/cellxgene"
target="_blank"
icon="git-branch"
text="Github"
/>
<MenuItem
target="_blank"
text={`cellxgene v${
libraryVersions && libraryVersions.cellxgene
? libraryVersions.cellxgene
: null
}`}
/>
<MenuItem text="MIT License" />
</Menu>
}
position={Position.BOTTOM_RIGHT}
>
<Button
type="button"
className="bp3-button bp3-icon-info-sign"
style={{
cursor: "pointer"
}}
/>
</Popover>
</div>
);
}
export default InformationMenu;
@@ -0,0 +1,64 @@
// jshint esversion: 6
import React from "react";
import { AnchorButton, Tooltip } from "@blueprintjs/core";
function InformationMenu(props) {
const {
resettingInterface,
undoDisabled,
redoDisabled,
resetInterface,
isResetDisabled,
dispatch
} = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Tooltip content="Undo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-undo"
disabled={undoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/undo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Redo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-redo"
disabled={redoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/redo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip
content="Reset cellxgene, clearing all selections"
position="left"
>
<AnchorButton
disabled={isResetDisabled()}
style={{ marginLeft: 10 }}
type="button"
loading={resettingInterface}
intent="none"
icon="refresh"
onClick={resetInterface}
data-testid="reset"
data-testclass={`resetting-${resettingInterface}`}
>
reset
</AnchorButton>
</Tooltip>
</div>
);
}
export default InformationMenu;
@@ -3,7 +3,6 @@
// https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html // https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html
import React from "react"; import React from "react";
import _ from "lodash";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Button, ButtonGroup } from "@blueprintjs/core"; import { Button, ButtonGroup } from "@blueprintjs/core";
import _regl from "regl"; import _regl from "regl";
-111
View File
@@ -73,114 +73,3 @@ let _API = {
if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.API; if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.API;
export const API = _API; export const API = _API;
export const ordinalColors = [
"#0ac115",
"#c10ab6",
"#c1710a",
"#0a5ac1",
"#c1150a",
"#0ab6c1",
"#5ac10a",
"#710ac1",
"#0ac171",
"#c10a5a",
"#b6c10a",
"#150ac1",
"#b2ffb7",
"#ffb2fa",
"#ffddb2",
"#b2d4ff",
"#ffb7b2",
"#b2faff",
"#d4ffb2",
"#ddb2ff",
"#b2ffdd",
"#ffb2d4",
"#faffb2",
"#b7b2ff",
"#27a908",
"#8b08a9",
"#a93a08",
"#0877a9",
"#a90827",
"#08a98b",
"#77a908",
"#3a08a9",
"#08a93a",
"#a90877",
"#a98b08",
"#0827a9",
"#00ff0f",
"#ff00ef",
"#ff8e00",
"#0070ff",
"#ff0f00",
"#00efff",
"#70ff00",
"#8e00ff",
"#00ff8e",
"#ff0070",
"#efff00",
"#0f00ff",
"#006606",
"#66005f",
"#663900",
"#002c66",
"#660600",
"#005f66",
"#2c6600",
"#390066",
"#006639",
"#66002c",
"#5f6600",
"#060066",
"#83ff65",
"#e165ff",
"#ff9565",
"#65cfff",
"#ff6583",
"#65ffe1",
"#cfff65",
"#9565ff",
"#65ff95",
"#ff65cf",
"#ffe165",
"#6583ff",
"#009909",
"#99008f",
"#995500",
"#004399",
"#990900",
"#008f99",
"#439900",
"#550099",
"#009955",
"#990043",
"#8f9900",
"#090099",
"#d9fecc",
"#f1ccfe",
"#fed7cc",
"#ccf3fe",
"#feccd9",
"#ccfef1",
"#f3fecc",
"#d7ccfe",
"#ccfed7",
"#feccf3",
"#fef1cc",
"#ccd9fe",
"#47ea51",
"#ea47e0",
"#eaa247",
"#478fea",
"#ea5147",
"#47e0ea",
"#8fea47",
"#a247ea",
"#47eaa2",
"#ea478f",
"#e0ea47",
"#5147ea"
];
+6 -1
View File
@@ -16,7 +16,7 @@ const Controls = (
diffexpGenes: [], diffexpGenes: [],
resettingInterface: false, resettingInterface: false,
graphInteractionMode: "select",
opacityForDeselectedCells: 0.2, opacityForDeselectedCells: 0.2,
scatterplotXXaccessor: null, // just easier to read scatterplotXXaccessor: null, // just easier to read
scatterplotYYaccessor: null, scatterplotYYaccessor: null,
@@ -138,6 +138,11 @@ const Controls = (
/******************************* /*******************************
User Events User Events
*******************************/ *******************************/
case "change graph interaction mode":
return {
...state,
graphInteractionMode: action.data
};
case "change opacity deselected cells in 2d graph background": case "change opacity deselected cells in 2d graph background":
return { return {
...state, ...state,
+2 -1
View File
@@ -74,7 +74,8 @@ const saveOnActions = new Set([
"set World to current selection", "set World to current selection",
"set clip quantiles", "set clip quantiles",
"set layout choice" "set layout choice",
"change graph interaction mode"
]); ]);
/** /**