From 6aeefb0fe6b7bfe39c279ca65461aabbbb18279e Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 13 Jun 2019 18:15:43 -0400 Subject: [PATCH] menubar (#804) * 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) --- client/__tests__/e2e/data.js | 4 +- client/__tests__/e2e/e2e.test.js | 17 +- client/__tests__/e2e/puppeteerUtils.js | 59 +- client/src/components/app.js | 23 +- .../src/components/categorical/categorical.js | 7 - .../src/components/continuous/continuous.js | 2 +- .../src/components/continuousLegend/index.js | 2 +- client/src/components/framework/logo.js | 2 +- .../geneExpression/expressionButtons.js | 101 --- client/src/components/geneExpression/index.js | 30 +- client/src/components/graph/graph.js | 619 ++---------------- client/src/components/leftSidebar/index.js | 61 ++ .../leftSidebar/topLeftLogoAndTitle.js | 71 ++ client/src/components/leftsidebar.js | 105 --- .../cellSetButtons.js | 5 +- client/src/components/menubar/clip.js | 130 ++++ client/src/components/menubar/index.js | 464 +++++++++++++ client/src/components/menubar/infoMenu.js | 61 ++ .../src/components/menubar/undoRedoReset.js | 64 ++ .../src/components/scatterplot/scatterplot.js | 1 - client/src/globals.js | 111 ---- client/src/reducers/controls.js | 7 +- client/src/reducers/undoableConfig.js | 3 +- 23 files changed, 977 insertions(+), 972 deletions(-) delete mode 100644 client/src/components/geneExpression/expressionButtons.js create mode 100644 client/src/components/leftSidebar/index.js create mode 100644 client/src/components/leftSidebar/topLeftLogoAndTitle.js delete mode 100644 client/src/components/leftsidebar.js rename client/src/components/{geneExpression => menubar}/cellSetButtons.js (93%) create mode 100644 client/src/components/menubar/clip.js create mode 100644 client/src/components/menubar/index.js create mode 100644 client/src/components/menubar/infoMenu.js create mode 100644 client/src/components/menubar/undoRedoReset.js diff --git a/client/__tests__/e2e/data.js b/client/__tests__/e2e/data.js index 1b81a3c8..02391376 100644 --- a/client/__tests__/e2e/data.js +++ b/client/__tests__/e2e/data.js @@ -27,7 +27,7 @@ export const datasets = { lasso: [ { "coordinates-as-percent": { x1: 0.05, y1: 0.25, x2: 0.15, y2: 0.35 }, - count: "101" + count: "71" } ], categorical: [ @@ -92,7 +92,7 @@ export const datasets = { }, lasso: { "coordinates-as-percent": { x1: 0.45, y1: 0.05, x2: 0.65, y2: 0.15 }, - count: "46" + count: "36" } }, scatter: { diff --git a/client/__tests__/e2e/e2e.test.js b/client/__tests__/e2e/e2e.test.js index 5ae7ff0a..783e8f31 100644 --- a/client/__tests__/e2e/e2e.test.js +++ b/client/__tests__/e2e/e2e.test.js @@ -154,10 +154,13 @@ describe("gene entry", () => { await utils.clickOn("section-bulk-add"); await utils.typeInto("input-bulk-add", testGenes.join(",")); 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 utils.clickOn("diffexp-button"); - const diffExpHists = await cxgActions.getAllHistograms("histogram-diffexp"); - expect(diffExpHists).toEqual( + const allHistograms = await cxgActions.getAllHistograms( + "histogram-diffexp", + data.diffexp["gene-results"] + ); + expect(allHistograms).toEqual( expect.arrayContaining(data.diffexp["gene-results"]) ); + expect(allHistograms.length).toEqual(data.diffexp["gene-results"].length); }); }); diff --git a/client/__tests__/e2e/puppeteerUtils.js b/client/__tests__/e2e/puppeteerUtils.js index 112414c6..69b068a1 100644 --- a/client/__tests__/e2e/puppeteerUtils.js +++ b/client/__tests__/e2e/puppeteerUtils.js @@ -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) { // only works for text without special characters await this.waitByID(testid); @@ -32,8 +52,8 @@ export const puppeteerUtils = puppeteerPage => ({ await puppeteerPage.waitFor(200); // select all - await puppeteerPage.click(selector, {clickCount: 3}) - await puppeteerPage.keyboard.type("Backspace") + await puppeteerPage.click(selector, { clickCount: 3 }); + await puppeteerPage.keyboard.type("Backspace"); await puppeteerPage.type(selector, text); }, @@ -77,20 +97,16 @@ export const cellxgeneActions = puppeteerPage => ({ await puppeteerPage.mouse.up(); }, - async getAllHistograms(testclass) { - await puppeteerUtils(puppeteerPage).waitByClass(testclass); - const histograms = await puppeteerPage.$$eval( - `[data-testclass=${testclass}]`, - els => { - return els.map(el => { - return el.dataset.testid.substring( - "histogram_".length, - el.dataset.testid.length - ); - }); - } + async getAllHistograms(testclass, testids) { + const histTestIds = testids.map(tid => `histogram-${tid}`); + // these load asynchronously, so we need to wait for each histogram individually + await puppeteerUtils(puppeteerPage).waitForAllByIds(histTestIds); + const allHistograms = await puppeteerUtils(puppeteerPage).getAllByClass( + testclass + ); + return allHistograms.map(hist => + hist.substr("histogram_".length, hist.length) ); - return histograms; }, async getAllCategoriesAndCounts(category) { @@ -180,11 +196,16 @@ export const cellxgeneActions = puppeteerPage => ({ 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).clearInputAndTypeInto("clip-min-input", min); - await puppeteerUtils(puppeteerPage).clearInputAndTypeInto("clip-max-input", max); + await puppeteerUtils(puppeteerPage).clearInputAndTypeInto( + "clip-min-input", + min + ); + await puppeteerUtils(puppeteerPage).clearInputAndTypeInto( + "clip-max-input", + max + ); await puppeteerUtils(puppeteerPage).clickOn("clip-commit"); } - }); diff --git a/client/src/components/app.js b/client/src/components/app.js index 89d0236c..8c2f1a11 100644 --- a/client/src/components/app.js +++ b/client/src/components/app.js @@ -4,9 +4,11 @@ import Helmet from "react-helmet"; import { connect } from "react-redux"; import Container from "./framework/container"; -import LeftSideBar from "./leftsidebar"; +import LeftSideBar from "./leftSidebar"; import Legend from "./continuousLegend"; import Graph from "./graph/graph"; +import MenuBar from "./menubar"; + import actions from "../actions"; @connect(state => ({ @@ -71,18 +73,23 @@ class App extends React.Component { loading cellxgene ) : null} -
- {loading ? null : } + {error ? (
- {loading ? null : } - + error loading
+ ) : null} +
+ {loading ? null : } + {loading ? null : } + {loading ? null : } +
); diff --git a/client/src/components/categorical/categorical.js b/client/src/components/categorical/categorical.js index 4c94c17b..437cf037 100644 --- a/client/src/components/categorical/categorical.js +++ b/client/src/components/categorical/categorical.js @@ -19,13 +19,6 @@ class Categories extends React.Component { padding: globals.leftSidebarSectionPadding }} > -

- Categorical Metadata -

{_.map(categoricalSelection, (catState, catName) => ( ))} diff --git a/client/src/components/continuous/continuous.js b/client/src/components/continuous/continuous.js index 959db97f..6f831fb2 100644 --- a/client/src/components/continuous/continuous.js +++ b/client/src/components/continuous/continuous.js @@ -48,7 +48,7 @@ class Continuous extends React.Component { } /* initial value for iterator to simulate index, ranges is an object */ - let zebra = -1; + let zebra = 0; return (
diff --git a/client/src/components/continuousLegend/index.js b/client/src/components/continuousLegend/index.js index 3e451485..3ef55850 100644 --- a/client/src/components/continuousLegend/index.js +++ b/client/src/components/continuousLegend/index.js @@ -2,7 +2,7 @@ import React from "react"; import { connect } from "react-redux"; import * as d3 from "d3"; -import { interpolateViridis, interpolateCool } from "d3-scale-chromatic"; +import { interpolateCool } from "d3-scale-chromatic"; // create continuous color legend // http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f diff --git a/client/src/components/framework/logo.js b/client/src/components/framework/logo.js index 2d2cbb84..6d378e11 100644 --- a/client/src/components/framework/logo.js +++ b/client/src/components/framework/logo.js @@ -4,7 +4,7 @@ import * as globals from "../../globals"; const Logo = props => { const { size } = props; return ( - + diff --git a/client/src/components/geneExpression/expressionButtons.js b/client/src/components/geneExpression/expressionButtons.js deleted file mode 100644 index 9185a365..00000000 --- a/client/src/components/geneExpression/expressionButtons.js +++ /dev/null @@ -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 ( -
- - - {!differential.diffExp ? ( - - - Compute Differential Expression - - - ) : null} - {differential.diffExp ? ( - - - - ) : null} -
- ); - } -} - -export default Expression; diff --git a/client/src/components/geneExpression/index.js b/client/src/components/geneExpression/index.js index 799b7074..dfe0f93f 100644 --- a/client/src/components/geneExpression/index.js +++ b/client/src/components/geneExpression/index.js @@ -21,7 +21,6 @@ import { postUserErrorToast, keepAroundErrorToast } from "../framework/toasters"; -import ExpressionButtons from "./expressionButtons"; const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => { if (!modifiers.matchesPredicate) { @@ -180,19 +179,7 @@ class GeneExpression extends React.Component { return (
-
-

- Selected Genes -

+
- Autosuggest + Autosuggest genes ) : null} @@ -291,7 +278,7 @@ class GeneExpression extends React.Component { onClick={this.handleBulkAddClick.bind(this)} loading={userDefinedGenesLoading} > - Add + Add genes @@ -318,15 +305,6 @@ class GeneExpression extends React.Component { : null}
-

- Differentially Expressed Genes -

- {differential.diffExp ? _.map(differential.diffExp, (value, index) => { const name = world.varAnnotations.at(value[0], varIndexName); diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index c49063f9..63f89b1a 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -3,82 +3,28 @@ import React from "react"; import * as d3 from "d3"; import { connect } from "react-redux"; import mat4 from "gl-mat4"; -import vec3 from "gl-vec3"; import _regl from "regl"; 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 setupSVGandBrushElements from "./setupSVGandBrush"; -import actions from "../../actions"; import _camera from "../../util/camera"; import _drawPoints from "./drawPointsRegl"; import scaleLinear from "../../util/scaleLinear"; -import { World } from "../../util/stateManager"; /* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */ @connect(state => ({ world: state.world, - universe: state.universe, crossfilter: state.crossfilter, - clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)), - clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)), responsive: state.responsive, 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, currentSelection: state.graphSelection.selection, - layoutChoice: state.layoutChoice + layoutChoice: state.layoutChoice, + graphInteractionMode: state.controls.graphInteractionMode })) 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) => { /* compute webgl coordinate buffer for each point @@ -115,7 +61,6 @@ class Graph extends React.Component { super(props); this.count = 0; this.graphPaddingTop = 0; - this.graphPaddingBottom = 45; this.graphPaddingRight = globals.leftSidebarWidth; this.renderCache = { X: null, @@ -127,9 +72,7 @@ class Graph extends React.Component { this.state = { svg: null, tool: null, - container: null, - mode: "select", - pendingClipPercentiles: null + container: null }; } @@ -146,10 +89,17 @@ class Graph extends React.Component { const sizeBuffer = regl.buffer(); // 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 = { 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 */ @@ -179,7 +129,7 @@ class Graph extends React.Component { }); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps) { const { renderCache } = this; const { world, @@ -188,14 +138,30 @@ class Graph extends React.Component { responsive, selectionTool, currentSelection, - layoutChoice + layoutChoice, + graphInteractionMode } = this.props; - const { reglRender, mode, regl, svg } = this.state; + const { reglRender, regl, svg } = this.state; let stateChanges = {}; - if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") { - reglRender.cancel(); - this.reglRenderState = "paused"; + if (reglRender) { + if ( + // 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) { @@ -293,7 +259,7 @@ class Graph extends React.Component { */ if ( currentSelection !== prevProps.currentSelection || - mode !== prevState.mode || + graphInteractionMode !== prevProps.graphInteractionMode || stateChanges.svg ) { 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) { /* 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 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; // 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 // in mapScreenToPoint(). @@ -769,367 +576,21 @@ class Graph extends React.Component { } render() { - const { - 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"; - } + const { responsive, graphInteractionMode } = this.props; return (
-
-
- - { - dispatch(actions.regraph()); - dispatch({ type: "increment graph render counter" }); - }} - > - subset to current selection - - - - - reset - - -
- -
-
- - { - dispatch({ type: "@@undoable/undo" }); - }} - style={{ - cursor: "pointer" - }} - /> - - - { - dispatch({ type: "@@undoable/redo" }); - }} - style={{ - cursor: "pointer" - }} - /> - -
- -
- - } - position={Position.BOTTOM_RIGHT} - content={ -
- - {layoutChoice.available.map(name => ( - - ))} - -
- } - /> -
- -
- - } - position={Position.BOTTOM_RIGHT} - onOpening={this.handleClipOpening} - onClosing={this.handleClipClosing} - content={ -
-
Clip all continuous values to percentile range
-
- - -
- } - /> - - - - -
- } - /> - -
-
- } - /> -
- -
- - - - - - - - - } - position={Position.BOTTOM_RIGHT} - > -
-
-
diff --git a/client/src/components/leftSidebar/index.js b/client/src/components/leftSidebar/index.js new file mode 100644 index 00000000..200f1f7a --- /dev/null +++ b/client/src/components/leftSidebar/index.js @@ -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 ( +
+ +
+ + + +
+ {scatterplotXXaccessor && scatterplotYYaccessor ? ( + + ) : null} +
+ ); + } +} + +export default LeftSideBar; diff --git a/client/src/components/leftSidebar/topLeftLogoAndTitle.js b/client/src/components/leftSidebar/topLeftLogoAndTitle.js new file mode 100644 index 00000000..380a10a3 --- /dev/null +++ b/client/src/components/leftSidebar/topLeftLogoAndTitle.js @@ -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 ( +
+ + + cell + × + gene + + + {datasetTitle} + +
+ ); + } +} + +export default LeftSideBar; diff --git a/client/src/components/leftsidebar.js b/client/src/components/leftsidebar.js deleted file mode 100644 index 5d259316..00000000 --- a/client/src/components/leftsidebar.js +++ /dev/null @@ -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 ( -
-

- - - cell - × - gene - - - {datasetTitle} - -

-
- - - -
- {scatterplotXXaccessor && scatterplotYYaccessor ? ( - - ) : null} -
- ); - } -} - -export default LeftSideBar; diff --git a/client/src/components/geneExpression/cellSetButtons.js b/client/src/components/menubar/cellSetButtons.js similarity index 93% rename from client/src/components/geneExpression/cellSetButtons.js rename to client/src/components/menubar/cellSetButtons.js index 841423c8..5822c748 100644 --- a/client/src/components/geneExpression/cellSetButtons.js +++ b/client/src/components/menubar/cellSetButtons.js @@ -32,7 +32,7 @@ class CellSetButton extends React.Component { render() { const { differential, eitherCellSetOneOrTwo } = this.props; const cellListName = `celllist${eitherCellSetOneOrTwo}`; - let cells_selected = differential[cellListName] + const cellsSelected = differential[cellListName] ? differential[cellListName].length : 0; return ( @@ -41,7 +41,6 @@ class CellSetButton extends React.Component { position="top" > - {cells_selected} + {cellsSelected} {" cells"} diff --git a/client/src/components/menubar/clip.js b/client/src/components/menubar/clip.js new file mode 100644 index 00000000..b9fb8cf8 --- /dev/null +++ b/client/src/components/menubar/clip.js @@ -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 ( +
+ + } + position={Position.BOTTOM_RIGHT} + onOpening={handleClipOpening} + onClosing={handleClipClosing} + content={ +
+
Clip all continuous values to percentile range
+
+ + +
+ } + /> + - + + +
+ } + /> + +
+
+ } + /> +
+ ); +} + +export default Clip; diff --git a/client/src/components/menubar/index.js b/client/src/components/menubar/index.js new file mode 100644 index 00000000..7c807f10 --- /dev/null +++ b/client/src/components/menubar/index.js @@ -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 ( +
+
+ + + {!differential.diffExp ? ( + + + Compute Differential Expression + + + ) : null} + + {differential.diffExp ? ( + + + + ) : null} +
+ + { + dispatch(actions.regraph()); + dispatch({ type: "increment graph render counter" }); + }} + > + subset to current selection + + +
+ +
+
+ + } + position={Position.BOTTOM_RIGHT} + content={ +
+ + {layoutChoice.available.map(name => ( + + ))} + +
+ } + /> +
+ + + +
+ ); + } +} + +export default MenuBar; diff --git a/client/src/components/menubar/infoMenu.js b/client/src/components/menubar/infoMenu.js new file mode 100644 index 00000000..736e2968 --- /dev/null +++ b/client/src/components/menubar/infoMenu.js @@ -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 ( +
+ + + + + + + + + } + position={Position.BOTTOM_RIGHT} + > +
+ ); +} + +export default InformationMenu; diff --git a/client/src/components/menubar/undoRedoReset.js b/client/src/components/menubar/undoRedoReset.js new file mode 100644 index 00000000..7baafcf7 --- /dev/null +++ b/client/src/components/menubar/undoRedoReset.js @@ -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 ( +
+ + { + dispatch({ type: "@@undoable/undo" }); + }} + style={{ + cursor: "pointer" + }} + /> + + + { + dispatch({ type: "@@undoable/redo" }); + }} + style={{ + cursor: "pointer" + }} + /> + + + + reset + + +
+ ); +} + +export default InformationMenu; diff --git a/client/src/components/scatterplot/scatterplot.js b/client/src/components/scatterplot/scatterplot.js index 1d14488d..bb331649 100644 --- a/client/src/components/scatterplot/scatterplot.js +++ b/client/src/components/scatterplot/scatterplot.js @@ -3,7 +3,6 @@ // https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html import React from "react"; -import _ from "lodash"; import { connect } from "react-redux"; import { Button, ButtonGroup } from "@blueprintjs/core"; import _regl from "regl"; diff --git a/client/src/globals.js b/client/src/globals.js index 4fc893dd..1ff89b05 100644 --- a/client/src/globals.js +++ b/client/src/globals.js @@ -73,114 +73,3 @@ let _API = { if (window.CELLXGENE && window.CELLXGENE.API) _API = window.CELLXGENE.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" -]; diff --git a/client/src/reducers/controls.js b/client/src/reducers/controls.js index 35d3cfb6..e9f77956 100644 --- a/client/src/reducers/controls.js +++ b/client/src/reducers/controls.js @@ -16,7 +16,7 @@ const Controls = ( diffexpGenes: [], resettingInterface: false, - + graphInteractionMode: "select", opacityForDeselectedCells: 0.2, scatterplotXXaccessor: null, // just easier to read scatterplotYYaccessor: null, @@ -138,6 +138,11 @@ const Controls = ( /******************************* User Events *******************************/ + case "change graph interaction mode": + return { + ...state, + graphInteractionMode: action.data + }; case "change opacity deselected cells in 2d graph background": return { ...state, diff --git a/client/src/reducers/undoableConfig.js b/client/src/reducers/undoableConfig.js index 59943b9b..a94d9c8e 100644 --- a/client/src/reducers/undoableConfig.js +++ b/client/src/reducers/undoableConfig.js @@ -74,7 +74,8 @@ const saveOnActions = new Set([ "set World to current selection", "set clip quantiles", - "set layout choice" + "set layout choice", + "change graph interaction mode" ]); /**