mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 04:38:11 +08:00
Compare commits
25
Commits
main
...
colinmegill/#632
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87ba3ff870 | ||
|
|
b553da0264 | ||
|
|
b67142e98f | ||
|
|
22a0921147 | ||
|
|
020e562f5c | ||
|
|
60d89b9478 | ||
|
|
c2b12abe2b | ||
|
|
2462d4afb1 | ||
|
|
02e79d502f | ||
|
|
9c2323b7bc | ||
|
|
e7200ce6c3 | ||
|
|
b7ffb2748d | ||
|
|
7ab8a8894d | ||
|
|
7b01e9e67b | ||
|
|
72ee670620 | ||
|
|
e21cac65bf | ||
|
|
bb5bbaac8a | ||
|
|
e29a6f72c2 | ||
|
|
ed97013277 | ||
|
|
2b29a152b9 | ||
|
|
f0e9b1ab91 | ||
|
|
c489221296 | ||
|
|
0a69af98c5 | ||
|
|
11570273e0 | ||
|
|
5f9d0a6b34 |
@@ -8,6 +8,7 @@ import LeftSideBar from "./leftSidebar";
|
||||
import RightSideBar from "./rightSidebar";
|
||||
import Legend from "./continuousLegend";
|
||||
import Graph from "./graph/graph";
|
||||
import Dotplot from "./dotplot";
|
||||
import MenuBar from "./menubar";
|
||||
import Autosave from "./autosave";
|
||||
import Embedding from "./embedding";
|
||||
@@ -23,6 +24,7 @@ import actions from "../actions";
|
||||
error: (state as any).controls.error,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS.
|
||||
graphRenderCounter: (state as any).controls.graphRenderCounter,
|
||||
layoutChoice: (state as any).layoutChoice,
|
||||
}))
|
||||
class App extends React.Component {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS.
|
||||
@@ -47,7 +49,7 @@ class App extends React.Component {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS.
|
||||
render() {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'loading' does not exist on type 'Readonl... Remove this comment to see the full error message
|
||||
const { loading, error, graphRenderCounter } = this.props;
|
||||
const { loading, error, graphRenderCounter, layoutChoice } = this.props;
|
||||
return (
|
||||
<Container>
|
||||
<Helmet title="cellxgene" />
|
||||
@@ -87,8 +89,14 @@ class App extends React.Component {
|
||||
<TermsOfServicePrompt />
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<Legend viewportRef={viewportRef} />
|
||||
{layoutChoice.dotplot && <Dotplot viewportRef={viewportRef} />}
|
||||
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '{ key: any; viewportRef: any; }' is not assi... Remove this comment to see the full error message */}
|
||||
<Graph key={graphRenderCounter} viewportRef={viewportRef} />
|
||||
<Graph
|
||||
key={graphRenderCounter}
|
||||
dotplotMode={layoutChoice.dotplot}
|
||||
viewportRef={viewportRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<RightSideBar />
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from "react";
|
||||
import { connect, shallowEqual } from "react-redux";
|
||||
|
||||
import * as d3 from "d3";
|
||||
|
||||
import memoize from "memoize-one";
|
||||
|
||||
import Async from "react-async";
|
||||
import ErrorLoading from "./err";
|
||||
import StillLoading from "./load";
|
||||
import Dot from "./dot";
|
||||
|
||||
import { createCategorySummaryFromDfCol } from "../../util/stateManager/controlsHelpers";
|
||||
|
||||
import { createColorQuery } from "../../util/stateManager/colorHelpers";
|
||||
|
||||
@connect((state) => ({
|
||||
annoMatrix: state.annoMatrix,
|
||||
colors: state.colors,
|
||||
genesets: state.genesets.genesets,
|
||||
pointDilation: state.pointDilation,
|
||||
differential: state.differential,
|
||||
dotplot: state.dotplot,
|
||||
}))
|
||||
class Column extends React.Component {
|
||||
static watchAsync(props, prevProps) {
|
||||
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||
}
|
||||
|
||||
createCategorySummaryFromDfCol = memoize(createCategorySummaryFromDfCol);
|
||||
|
||||
fetchAsyncProps = async (props) => {
|
||||
const {
|
||||
annoMatrix,
|
||||
colors,
|
||||
_geneSymbol,
|
||||
_geneIndex,
|
||||
metadataField,
|
||||
} = props.watchProps;
|
||||
|
||||
const [categoryData, categorySummary, colorData] = await this.fetchData(
|
||||
annoMatrix,
|
||||
metadataField,
|
||||
colors,
|
||||
_geneSymbol,
|
||||
_geneIndex
|
||||
);
|
||||
|
||||
return {
|
||||
categoryData,
|
||||
categorySummary,
|
||||
colorData,
|
||||
};
|
||||
};
|
||||
|
||||
async fetchData(annoMatrix, metadataField, colors, _geneSymbol) {
|
||||
/*
|
||||
fetch our data and the color-by data if appropriate, and then build a summary
|
||||
of our category and a color table for the color-by annotation.
|
||||
*/
|
||||
const { schema } = annoMatrix;
|
||||
const { colorMode } = colors;
|
||||
const { genesets, differential } = this.props;
|
||||
let colorDataPromise = Promise.resolve(null);
|
||||
|
||||
const query = createColorQuery(
|
||||
colorMode,
|
||||
_geneSymbol,
|
||||
schema,
|
||||
genesets,
|
||||
differential.diffExp
|
||||
);
|
||||
if (query) colorDataPromise = annoMatrix.fetch(...query);
|
||||
|
||||
const [categoryData, colorData] = await Promise.all([
|
||||
annoMatrix.fetch("obs", metadataField),
|
||||
colorDataPromise,
|
||||
]);
|
||||
|
||||
// our data
|
||||
const column = categoryData.icol(0);
|
||||
const colSchema = schema.annotations.obsByName[metadataField];
|
||||
|
||||
const categorySummary = this.createCategorySummaryFromDfCol(
|
||||
column,
|
||||
colSchema
|
||||
);
|
||||
|
||||
return [categoryData, categorySummary, colorData];
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
annoMatrix,
|
||||
pointDilation,
|
||||
colors,
|
||||
viewport,
|
||||
_geneSymbol,
|
||||
_geneIndex,
|
||||
rowColumnSize,
|
||||
metadataField,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<g key={_geneSymbol}>
|
||||
<Async
|
||||
watchFn={Column.watchAsync}
|
||||
promiseFn={this.fetchAsyncProps}
|
||||
watchProps={{
|
||||
annoMatrix,
|
||||
pointDilation,
|
||||
colors,
|
||||
viewport,
|
||||
_geneSymbol,
|
||||
_geneIndex,
|
||||
metadataField,
|
||||
}}
|
||||
>
|
||||
<Async.Pending initial>
|
||||
<StillLoading width={viewport.width} height={viewport.height} />
|
||||
</Async.Pending>
|
||||
<Async.Rejected>
|
||||
{(error) => (
|
||||
<ErrorLoading
|
||||
width={viewport.width}
|
||||
height={viewport.height}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</Async.Rejected>
|
||||
<Async.Fulfilled persist>
|
||||
{(asyncProps) => {
|
||||
const { categoryData, categorySummary, colorData } = asyncProps;
|
||||
|
||||
if (!_geneSymbol || !colorData) return null;
|
||||
|
||||
/* TODO(colinmegill) #632 wire to dotplot */
|
||||
|
||||
const groupBy = categoryData.col(metadataField);
|
||||
const col = colorData.icol(0);
|
||||
const range = col.summarize();
|
||||
|
||||
const histogramMap = col.histogram(
|
||||
100,
|
||||
[range.min, range.max],
|
||||
groupBy
|
||||
);
|
||||
|
||||
const categories =
|
||||
annoMatrix?.schema?.annotations?.obsByName[metadataField]
|
||||
?.categories;
|
||||
const cellCategories = groupBy.asArray();
|
||||
const geneExpressions = col.asArray();
|
||||
let mean;
|
||||
const meanGeneExpressions = {};
|
||||
for (const c of categories) {
|
||||
const arr = [];
|
||||
for (let i = 0; i < geneExpressions.length; i += 1) {
|
||||
if (cellCategories[i] === c) {
|
||||
arr.push(geneExpressions[i]);
|
||||
}
|
||||
}
|
||||
mean = arr.reduce((a, b) => a + b) / arr.length;
|
||||
meanGeneExpressions[c] = mean;
|
||||
}
|
||||
|
||||
const columnColorScale = d3
|
||||
.scaleLinear()
|
||||
.domain(d3.extent(Object.values(meanGeneExpressions)))
|
||||
.range([1, 0]);
|
||||
|
||||
return categorySummary.categoryValues.map(
|
||||
(val, _categoryValueIndex) => {
|
||||
return (
|
||||
<Dot
|
||||
key={val}
|
||||
categoryValue={val}
|
||||
_categoryValueIndex={_categoryValueIndex}
|
||||
histogramMap={histogramMap}
|
||||
_geneSymbol={_geneSymbol}
|
||||
_geneIndex={_geneIndex}
|
||||
colorData={colorData}
|
||||
rowColumnSize={rowColumnSize}
|
||||
columnColorScale={columnColorScale}
|
||||
meanGeneExpression={meanGeneExpressions[val]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
}}
|
||||
</Async.Fulfilled>
|
||||
</Async>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Column;
|
||||
|
||||
// updateColorTable(colors, colorDf) {
|
||||
// const { annoMatrix } = this.props;
|
||||
// const { schema } = annoMatrix;
|
||||
|
||||
// /* update color table state */
|
||||
// if (!colors || !colorDf) {
|
||||
// return createColorTable(
|
||||
// null, // default mode
|
||||
// null,
|
||||
// null,
|
||||
// schema,
|
||||
// null
|
||||
// );
|
||||
// }
|
||||
|
||||
// const { colorAccessor, userColors, colorMode } = colors;
|
||||
// return createColorTable(
|
||||
// colorMode,
|
||||
// colorAccessor /* TODO(colinmegill) #632 dotplot wiring */,
|
||||
// colorDf,
|
||||
// schema,
|
||||
// userColors
|
||||
// );
|
||||
// }
|
||||
|
||||
// createColorByQuery(colors) {
|
||||
// const { annoMatrix, genesets, differential } = this.props;
|
||||
// const { schema } = annoMatrix;
|
||||
// const { colorMode, colorAccessor } = colors;
|
||||
|
||||
// return createColorQuery(
|
||||
// colorMode,
|
||||
// colorAccessor /* TODO(colinmegill) #632 dotplot wiring */,
|
||||
// schema,
|
||||
// genesets,
|
||||
// differential.diffExp
|
||||
// );
|
||||
// }
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { interpolateCool } from "d3-scale-chromatic";
|
||||
import * as d3 from "d3";
|
||||
|
||||
const Dot = (props) => {
|
||||
const {
|
||||
categoryValue,
|
||||
_categoryValueIndex,
|
||||
histogramMap,
|
||||
_geneSymbol,
|
||||
_geneIndex,
|
||||
rowColumnSize,
|
||||
columnColorScale,
|
||||
meanGeneExpression,
|
||||
} = props;
|
||||
|
||||
const bins = histogramMap.has(categoryValue)
|
||||
? histogramMap.get(categoryValue)
|
||||
: new Array(100).fill(0);
|
||||
|
||||
const totalCells = bins.reduce(
|
||||
(acc, current) => acc + current
|
||||
); /* SUM REMAINING ELEMENTS */
|
||||
/*
|
||||
TODO(colinmegill) #632 this is a heuristic —
|
||||
we need to figure out what non expressing means
|
||||
and use it, rather than shifting off the first bin
|
||||
for prototyping
|
||||
*/
|
||||
bins.shift(); /* MUTATES, REMOVES FIRST ELEMENT */
|
||||
|
||||
const expressing = bins.reduce(
|
||||
(acc, current) => acc + current
|
||||
); /* SUM REMAINING ELEMENTS */
|
||||
|
||||
/* TODO(colinmegill) #632 scale between correct dimensions */
|
||||
const paddingEquivalentToRowColumnIndexOffset = 8;
|
||||
/* domain is some fraction of the cells expressing, percent as decimal */
|
||||
const dotscale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, 1])
|
||||
.range([0, rowColumnSize - paddingEquivalentToRowColumnIndexOffset]);
|
||||
|
||||
const _radius = dotscale(expressing / totalCells);
|
||||
|
||||
return (
|
||||
<g
|
||||
id={`row_${categoryValue}_${_geneSymbol}`}
|
||||
key={`${_categoryValueIndex}_${categoryValue}`}
|
||||
transform={`translate(${_geneIndex * rowColumnSize}, ${
|
||||
_categoryValueIndex * rowColumnSize
|
||||
})`}
|
||||
>
|
||||
{_geneIndex === 0 && (
|
||||
<text
|
||||
textAnchor="end"
|
||||
style={{ fill: "black", font: "12px Roboto Condensed" }}
|
||||
>
|
||||
{categoryValue}
|
||||
</text>
|
||||
)}
|
||||
<circle
|
||||
r={_radius}
|
||||
cx="11"
|
||||
cy="-3.5"
|
||||
style={{
|
||||
fill: interpolateCool(columnColorScale(meanGeneExpression)),
|
||||
fillOpacity: 1,
|
||||
stroke: "none",
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dot;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
const ErrorLoading = ({ error, width, height }) => {
|
||||
console.log(error); // log to console as this is an unepected error
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
fontWeight: 500,
|
||||
top: height / 2,
|
||||
left: globals.leftSidebarWidth + width / 2 - 50,
|
||||
}}
|
||||
>
|
||||
<span>Failure loading dotplot</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorLoading;
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import Column from "./column";
|
||||
|
||||
@connect((state) => ({
|
||||
layoutChoice: state.layoutChoice,
|
||||
genesets: state.genesets.genesets,
|
||||
dotplot: state.dotplot,
|
||||
}))
|
||||
class Dotplot extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const viewport = this.getViewportDimensions();
|
||||
this.dotplotTopPadding = 120;
|
||||
this.dotplotLeftPadding = 170;
|
||||
this.rowColumnSize = 15;
|
||||
this.dotplotBrowserScalingFactor = 0.65;
|
||||
|
||||
this.state = {
|
||||
viewport,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
/* chrome only, which is support matrix as of 2021 */
|
||||
document.body.style.zoom = `${this.dotplotBrowserScalingFactor * 100}%`;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
document.body.style.zoom = "100%";
|
||||
}
|
||||
|
||||
getViewportDimensions = () => {
|
||||
const { viewportRef } = this.props;
|
||||
return {
|
||||
height: viewportRef.clientHeight,
|
||||
width: viewportRef.clientWidth,
|
||||
};
|
||||
};
|
||||
|
||||
render() {
|
||||
const { viewport } = this.state;
|
||||
const { genesets, dotplot } = this.props;
|
||||
|
||||
let _geneset = null;
|
||||
let _genes = null;
|
||||
|
||||
if (dotplot.column) {
|
||||
_geneset = genesets.get(dotplot.column);
|
||||
_genes = Array.from(_geneset.genes.keys());
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="dotplot-wrapper"
|
||||
style={{
|
||||
position: "relative",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: -9999,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
id="dotplot"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
width={viewport.width * (1 + this.dotplotBrowserScalingFactor)}
|
||||
height={viewport.height * (1 + this.dotplotBrowserScalingFactor)}
|
||||
>
|
||||
<g
|
||||
id="dotplot_help_text"
|
||||
transform={`translate(${this.dotplotLeftPadding},${this.dotplotTopPadding})`}
|
||||
>
|
||||
<text>
|
||||
{!dotplot.row && "Select a row"}{" "}
|
||||
{!dotplot.column && "Select a column"}
|
||||
</text>
|
||||
</g>
|
||||
{/* ASYNC HERE */}
|
||||
{dotplot.row && dotplot.column && (
|
||||
<g
|
||||
id="dotplot_interface_margin"
|
||||
transform={`translate(${this.dotplotLeftPadding},${this.dotplotTopPadding})`}
|
||||
>
|
||||
{/* Acaa1b, Mal, Foxq1 ... across the top of the dotplot */}
|
||||
<g id="dotplot_column_labels" transform="translate(14,-13)">
|
||||
{_genes.map((_geneSymbol, _geneIndexInGeneset) => (
|
||||
<text
|
||||
key={_geneSymbol}
|
||||
x={0}
|
||||
y={0}
|
||||
transform={`translate(${
|
||||
_geneIndexInGeneset * this.rowColumnSize
|
||||
}) rotate(270)`}
|
||||
style={{ fill: "black", font: "12px Roboto Condensed" }}
|
||||
>
|
||||
{_geneSymbol}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
{/* loop over genes in the geneset, */}
|
||||
<g id="dotplot_columns">
|
||||
{_genes.map((_geneSymbol, _geneIndexInGeneset) => (
|
||||
<Column
|
||||
key={_geneSymbol}
|
||||
_geneSymbol={_geneSymbol}
|
||||
_geneIndex={_geneIndexInGeneset}
|
||||
viewport={viewport}
|
||||
rowColumnSize={this.rowColumnSize}
|
||||
metadataField={dotplot.row}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dotplot;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
const StillLoading = ({ width, height }) => {
|
||||
/*
|
||||
Render a busy/loading indicator
|
||||
*/
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
fontWeight: 500,
|
||||
top: height / 2,
|
||||
width,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
justifyItems: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontStyle: "italic" }}>Loading dotplot</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StillLoading;
|
||||
@@ -26,6 +26,7 @@ type EmbeddingState = any;
|
||||
schema: (state as any).annoMatrix?.schema,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any --- FIXME: disabled temporarily on migrate to TS.
|
||||
crossfilter: (state as any).obsCrossfilter,
|
||||
dotplot: (state as any).layoutChoice.dotplot,
|
||||
}))
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types --- FIXME: disabled temporarily on migrate to TS.
|
||||
class Embedding extends React.PureComponent<{}, EmbeddingState> {
|
||||
@@ -45,13 +46,13 @@ class Embedding extends React.PureComponent<{}, EmbeddingState> {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS.
|
||||
render() {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'layoutChoice' does not exist on type 'Re... Remove this comment to see the full error message
|
||||
const { layoutChoice, schema, crossfilter } = this.props;
|
||||
const { layoutChoice, schema, crossfilter, dotplot } = this.props;
|
||||
const { annoMatrix } = crossfilter;
|
||||
return (
|
||||
<ButtonGroup
|
||||
style={{
|
||||
position: "absolute",
|
||||
display: "inherit",
|
||||
display: dotplot ? "none" : "inherit",
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
zIndex: 9999,
|
||||
|
||||
@@ -914,6 +914,7 @@ class Graph extends React.Component<{}, GraphState> {
|
||||
pointDilation,
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'crossfilter' does not exist on ... Remove this comment to see the full error message
|
||||
crossfilter,
|
||||
dotplotMode,
|
||||
} = this.props;
|
||||
const { modelTF, projectionTF, camera, viewport, regl } = this.state;
|
||||
const cameraTF = camera?.view()?.slice();
|
||||
@@ -924,6 +925,7 @@ class Graph extends React.Component<{}, GraphState> {
|
||||
position: "relative",
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: dotplotMode ? "none" : "inherit",
|
||||
}}
|
||||
>
|
||||
<GraphOverlayLayer
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
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 AuthButtons from "./authButtons";
|
||||
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,
|
||||
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,
|
||||
auth: state.config?.authentication,
|
||||
userInfo: state.userInfo,
|
||||
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,
|
||||
dotplotEnabled: state.layoutChoice.dotplot,
|
||||
};
|
||||
})
|
||||
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());
|
||||
};
|
||||
|
||||
handleDotplotToggle = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({ type: "toggle dotplot" });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatch,
|
||||
disableDiffexp,
|
||||
undoDisabled,
|
||||
redoDisabled,
|
||||
selectionTool,
|
||||
clipPercentileMin,
|
||||
clipPercentileMax,
|
||||
graphInteractionMode,
|
||||
showCentroidLabels,
|
||||
categoricalSelection,
|
||||
colorAccessor,
|
||||
subsetPossible,
|
||||
subsetResetPossible,
|
||||
userInfo,
|
||||
auth,
|
||||
dotplotEnabled,
|
||||
} = 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,
|
||||
}}
|
||||
>
|
||||
<AuthButtons {...{ auth, userInfo }} />
|
||||
<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="Enable dotplot mode (hides embedding)"
|
||||
position="bottom"
|
||||
>
|
||||
<AnchorButton
|
||||
className={styles.menubarButton}
|
||||
type="button"
|
||||
data-testid="dotplot-toggle"
|
||||
icon="layout-grid"
|
||||
onClick={this.handleDotplotToggle}
|
||||
active={dotplotEnabled}
|
||||
intent={dotplotEnabled ? "success" : "none"}
|
||||
/>
|
||||
</Tooltip>
|
||||
<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={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;
|
||||
@@ -92,6 +92,7 @@ export default class MiniHistogram extends React.PureComponent {
|
||||
height,
|
||||
borderBottom: "solid rgb(230, 230, 230) 0.25px",
|
||||
}}
|
||||
className="mini-histo"
|
||||
width={width}
|
||||
height={height}
|
||||
ref={this.canvasRef}
|
||||
|
||||
@@ -81,6 +81,7 @@ export default class MiniStackedBar extends React.PureComponent {
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
className="mini-stacked-bar-canvas"
|
||||
width={width}
|
||||
height={height}
|
||||
ref={this.canvasRef}
|
||||
|
||||
@@ -566,6 +566,7 @@ class Scatterplot extends React.PureComponent<{}, State> {
|
||||
width={width}
|
||||
height={height}
|
||||
data-testid="scatterplot"
|
||||
className="scatterplot-canvas"
|
||||
style={{
|
||||
marginLeft: margin.left,
|
||||
marginTop: margin.top,
|
||||
|
||||
@@ -5,6 +5,7 @@ Color By UI state
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types --- FIXME: disabled temporarily on migrate to TS.
|
||||
const ColorsReducer = (
|
||||
state = {
|
||||
/* TODO(colinmegill) #632 remove hardcode for dev */
|
||||
colorMode: null /* by continuous, by expression */,
|
||||
colorAccessor: null /* tissue, Apod */,
|
||||
},
|
||||
@@ -53,6 +54,17 @@ const ColorsReducer = (
|
||||
};
|
||||
}
|
||||
|
||||
case "toggle dotplot": {
|
||||
return {
|
||||
...state,
|
||||
colorMode:
|
||||
state.colorMode === "color by dotplot columns"
|
||||
? null
|
||||
: "color by dotplot columns",
|
||||
colorAccessor: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "color by categorical metadata":
|
||||
case "color by continuous metadata": {
|
||||
/* toggle between this mode and reset */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const Dotplot = (
|
||||
state = {
|
||||
row: null,
|
||||
column: null,
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "set dotplot row":
|
||||
return {
|
||||
...state,
|
||||
row: action.data,
|
||||
};
|
||||
case "set dotplot column":
|
||||
return {
|
||||
...state,
|
||||
column: action.data,
|
||||
};
|
||||
case "toggle dotplot":
|
||||
return {
|
||||
...state,
|
||||
row: null,
|
||||
column: null,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Dotplot;
|
||||
@@ -17,6 +17,7 @@ import controls from "./controls";
|
||||
import annotations from "./annotations";
|
||||
import genesets from "./genesets";
|
||||
import genesetsUI from "./genesetsUI";
|
||||
import dotplot from "./dotplot";
|
||||
import autosave from "./autosave";
|
||||
import centroidLabels from "./centroidLabels";
|
||||
import pointDialation from "./pointDilation";
|
||||
@@ -32,6 +33,7 @@ const Reducer = undoable(
|
||||
["annotations", annotations],
|
||||
["genesets", genesets],
|
||||
["genesetsUI", genesetsUI],
|
||||
["dotplot", dotplot],
|
||||
["layoutChoice", layoutChoice],
|
||||
["categoricalSelection", categoricalSelection],
|
||||
["continuousSelection", continuousSelection],
|
||||
|
||||
@@ -28,6 +28,7 @@ function setToDefaultLayout(schema: any) {
|
||||
const LayoutChoice = (
|
||||
state = {
|
||||
available: [], // all available choices
|
||||
dotplot: false, // is the dotplot toggled on, or not
|
||||
current: undefined, // name of the current layout, eg, 'umap'
|
||||
currentDimNames: [], // dimension name
|
||||
},
|
||||
@@ -46,6 +47,13 @@ const LayoutChoice = (
|
||||
};
|
||||
}
|
||||
|
||||
case "toggle dotplot": {
|
||||
return {
|
||||
...state,
|
||||
dotplot: !state.dotplot,
|
||||
};
|
||||
}
|
||||
|
||||
case "set layout choice": {
|
||||
const { schema } = nextSharedState.annoMatrix;
|
||||
const current = action.layoutChoice;
|
||||
|
||||
@@ -88,6 +88,10 @@ const saveOnActions = new Set<string>([
|
||||
"color by expression",
|
||||
"color by geneset mean expression",
|
||||
|
||||
"set dotplot row",
|
||||
"set dotplot column",
|
||||
"toggle dotplot",
|
||||
|
||||
"show centroid labels for category",
|
||||
|
||||
"set scatterplot x",
|
||||
|
||||
@@ -65,6 +65,28 @@ export function createColorQuery(
|
||||
},
|
||||
];
|
||||
}
|
||||
case "color by dotplot columns": {
|
||||
/*
|
||||
Color by COLUMNS is a mode at the UI level,
|
||||
as we are going to be keeping track of many color scales —
|
||||
one per column in the dotplot. The query is for
|
||||
one gene at a time.
|
||||
*/
|
||||
const varIndex = schema?.annotations?.var?.index;
|
||||
|
||||
if (!varIndex) return null;
|
||||
|
||||
return [
|
||||
"X",
|
||||
{
|
||||
where: {
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: colorByAccessor,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user