Files
cellxgene/client/src/components/menubar/index.js
Colin Megill caa1526eb6 intent
2021-12-07 15:59:05 -08:00

345 lines
11 KiB
JavaScript

import React from "react";
import { connect } from "react-redux";
import { ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
import * as globals from "../../globals";
import styles from "./menubar.css";
import actions from "../../actions";
import Clip from "./clip";
import Subset from "./subset";
import UndoRedoReset from "./undoRedo";
import DiffexpButtons from "./diffexpButtons";
import { getEmbSubsetView } from "../../util/stateManager/viewStackHelpers";
@connect((state) => {
const { annoMatrix } = state;
const crossfilter = state.obsCrossfilter;
const selectedCount = crossfilter.countSelected();
const subsetPossible =
selectedCount !== 0 && selectedCount !== crossfilter.size(); // ie, not all and not none are selected
const embSubsetView = getEmbSubsetView(annoMatrix);
const subsetResetPossible = !embSubsetView
? annoMatrix.nObs !== annoMatrix.schema.dataframe.nObs
: annoMatrix.nObs !== embSubsetView.nObs;
return {
subsetPossible,
subsetResetPossible,
graphInteractionMode: state.controls.graphInteractionMode,
imageUnderlay: state.imageUnderlay,
clipPercentileMin: Math.round(100 * (annoMatrix?.clipRange?.[0] ?? 0)),
clipPercentileMax: Math.round(100 * (annoMatrix?.clipRange?.[1] ?? 1)),
userDefinedGenes: state.controls.userDefinedGenes,
colorAccessor: state.colors.colorAccessor,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
libraryVersions: state.config?.library_versions,
undoDisabled: state["@@undoable/past"].length === 0,
redoDisabled: state["@@undoable/future"].length === 0,
aboutLink: state.config?.links?.["about-dataset"],
disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false,
diffexpMayBeSlow:
state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
showCentroidLabels: state.centroidLabels.showLabels,
tosURL: state.config?.parameters?.about_legal_tos,
privacyURL: state.config?.parameters?.about_legal_privacy,
categoricalSelection: state.categoricalSelection,
};
})
class MenuBar extends React.PureComponent {
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 {
clipPercentileMin: currentClipMin,
clipPercentileMax: currentClipMax,
} = this.props;
// 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 (!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(actions.clipAction(min, max));
};
handleClipOpening = () => {
const { clipPercentileMin, clipPercentileMax } = this.props;
this.setState({
pendingClipPercentiles: { clipPercentileMin, clipPercentileMax },
});
};
handleClipClosing = () => {
this.setState({ pendingClipPercentiles: null });
};
handleCentroidChange = () => {
const { dispatch, showCentroidLabels } = this.props;
dispatch({
type: "show centroid labels for category",
showLabels: !showCentroidLabels,
});
};
handleSubset = () => {
const { dispatch } = this.props;
dispatch(actions.subsetAction());
};
handleSubsetReset = () => {
const { dispatch } = this.props;
dispatch(actions.resetSubsetAction());
};
render() {
const {
dispatch,
disableDiffexp,
undoDisabled,
redoDisabled,
selectionTool,
clipPercentileMin,
clipPercentileMax,
graphInteractionMode,
showCentroidLabels,
categoricalSelection,
colorAccessor,
subsetPossible,
subsetResetPossible,
imageUnderlay,
} = this.props;
const { pendingClipPercentiles } = this.state;
const isColoredByCategorical = !!categoricalSelection?.[colorAccessor];
// constants used to create selection tool button
const [selectionTooltip, selectionButtonIcon] =
selectionTool === "brush"
? ["Brush selection", "Lasso selection"]
: ["select", "polygon-filter"];
return (
<div
style={{
position: "absolute",
right: 8,
top: 0,
display: "flex",
flexDirection: "row-reverse",
alignItems: "flex-start",
flexWrap: "wrap",
justifyContent: "flex-start",
zIndex: 3,
}}
>
<UndoRedoReset
dispatch={dispatch}
undoDisabled={undoDisabled}
redoDisabled={redoDisabled}
/>
<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
}
/>
<Tooltip
content="When a category is colored by, show labels on the graph"
position="bottom"
disabled={graphInteractionMode === "zoom"}
>
<AnchorButton
className={styles.menubarButton}
type="button"
data-testid="centroid-label-toggle"
icon="property"
onClick={this.handleCentroidChange}
active={showCentroidLabels}
intent={showCentroidLabels ? "primary" : "none"}
disabled={!isColoredByCategorical}
/>
</Tooltip>
<ButtonGroup className={styles.menubarButton}>
<Tooltip
content={"Toggle image"}
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="toggle-image-underlay"
icon={"media"}
intent={imageUnderlay.isActive ? "primary" : "none"}
active={imageUnderlay.isActive}
onClick={() => {
dispatch({
type: "toggle image underlay",
});
}}
/>
</Tooltip>
</ButtonGroup>
<ButtonGroup className={styles.menubarButton}>
<Tooltip
content={selectionTooltip}
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="mode-lasso"
icon={selectionButtonIcon}
active={graphInteractionMode === "select"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "select",
});
}}
/>
</Tooltip>
<Tooltip
content="Drag to pan, scroll to zoom"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="mode-pan-zoom"
icon="zoom-in"
active={graphInteractionMode === "zoom"}
onClick={() => {
dispatch({
type: "change graph interaction mode",
data: "zoom",
});
}}
/>
</Tooltip>
</ButtonGroup>
<Subset
subsetPossible={subsetPossible}
subsetResetPossible={subsetResetPossible}
handleSubset={this.handleSubset}
handleSubsetReset={this.handleSubsetReset}
/>
{disableDiffexp ? null : <DiffexpButtons />}
</div>
);
}
}
export default MenuBar;