mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-07 04:48:11 +08:00
componetize histogram
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
export default function clamp(val, rng) {
|
||||||
|
return Math.max(Math.min(val, rng[1]), rng[0]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
|
const ErrorLoading = ({ displayName, error, zebra }) => {
|
||||||
|
console.log(error); // log to console as this is unexpected
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: globals.leftSidebarSectionPadding,
|
||||||
|
backgroundColor: zebra ? globals.lightestGrey : "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{`Failure loading ${displayName}`}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorLoading;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const HistogramFooter = React.memo(
|
||||||
|
({
|
||||||
|
displayName,
|
||||||
|
hideRanges,
|
||||||
|
rangeMin,
|
||||||
|
rangeMax,
|
||||||
|
rangeColorMin,
|
||||||
|
rangeColorMax,
|
||||||
|
logFoldChange,
|
||||||
|
pvalAdj,
|
||||||
|
}) => {
|
||||||
|
/*
|
||||||
|
Footer of each histogram. Will render range, title, and optionally
|
||||||
|
differential expression info.
|
||||||
|
|
||||||
|
Required props:
|
||||||
|
* displayName - the displayName, aka "n_genes", "FOXP2", etc.
|
||||||
|
* hideRanges - true/false, enables/disable rendering of ranges
|
||||||
|
* range - length two array, [min, max], containing the range values to display
|
||||||
|
* rangeColor - length two array, [mincolor, maxcolor], each a CSS color
|
||||||
|
* logFoldChange - lfc to display, optional.
|
||||||
|
* pValue - pValue to display, optional.
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: hideRanges ? "center" : "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: rangeColorMin,
|
||||||
|
display: hideRanges ? "none" : "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
min {rangeMin.toPrecision(4)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testclass="brushable-histogram-field-name"
|
||||||
|
style={{ fontStyle: "italic" }}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: hideRanges ? "block" : "none" }}>
|
||||||
|
: {rangeMin}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: rangeColorMax,
|
||||||
|
display: hideRanges ? "none" : "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
max {rangeMax.toPrecision(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logFoldChange && pvalAdj ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "baseline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>log fold change:</strong>
|
||||||
|
{` ${logFoldChange.toPrecision(4)}`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 7,
|
||||||
|
padding: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>p-value (adj):</strong>
|
||||||
|
{pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HistogramFooter;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
|
const HistogramHeader = React.memo(
|
||||||
|
({
|
||||||
|
fieldId,
|
||||||
|
isColorBy,
|
||||||
|
onColorByClick,
|
||||||
|
onRemoveClick,
|
||||||
|
isScatterPlotX,
|
||||||
|
isScatterPlotY,
|
||||||
|
onScatterPlotXClick,
|
||||||
|
onScatterPlotYClick,
|
||||||
|
isObs,
|
||||||
|
}) => {
|
||||||
|
/*
|
||||||
|
Render the toolbar for the histogram. Props:
|
||||||
|
* fieldId - field identifier, used for various IDs
|
||||||
|
* isColorBy - true/false, is this the current color-by
|
||||||
|
* onColorByClick - color-by click handler
|
||||||
|
* onRemoveClick - optional handler for remove. Button will not render if not defined.
|
||||||
|
* isScatterPlotX - optional, true/false if currently the X scatterplot field
|
||||||
|
* isScatterPlotY - optional, true/false if currently the Y scatterplot field
|
||||||
|
* onScatterPlotXClick - optional, handler for scatterPlot X button.
|
||||||
|
* onScatterPlotYClick - optional, handler for scatterPlot X button.
|
||||||
|
|
||||||
|
Scatterplot controls will not render if either handler unspecified.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const memoizedColorByCallback = useCallback(
|
||||||
|
() => onColorByClick(fieldId, isObs),
|
||||||
|
[fieldId, isObs]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onScatterPlotXClick && onScatterPlotYClick ? (
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style={{ marginRight: 7 }}
|
||||||
|
className="bp3-icon-standard bp3-icon-scatter-plot"
|
||||||
|
/>
|
||||||
|
<ButtonGroup style={{ marginRight: 7 }}>
|
||||||
|
<Button
|
||||||
|
data-testid={`plot-x-${fieldId}`}
|
||||||
|
onClick={onScatterPlotXClick}
|
||||||
|
active={isScatterPlotX}
|
||||||
|
intent={isScatterPlotX ? "primary" : "none"}
|
||||||
|
>
|
||||||
|
plot x
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid={`plot-y-${fieldId}`}
|
||||||
|
onClick={onScatterPlotYClick}
|
||||||
|
active={isScatterPlotY}
|
||||||
|
intent={isScatterPlotY ? "primary" : "none"}
|
||||||
|
>
|
||||||
|
plot y
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{onRemoveClick ? (
|
||||||
|
<Button
|
||||||
|
minimal
|
||||||
|
onClick={onRemoveClick}
|
||||||
|
style={{
|
||||||
|
color: globals.blue,
|
||||||
|
cursor: "pointer",
|
||||||
|
marginLeft: 7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Tooltip
|
||||||
|
content="Use as color scale"
|
||||||
|
position="bottom"
|
||||||
|
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={memoizedColorByCallback}
|
||||||
|
active={isColorBy}
|
||||||
|
intent={isColorBy ? "primary" : "none"}
|
||||||
|
data-testclass="colorby"
|
||||||
|
data-testid={`colorby-${fieldId}`}
|
||||||
|
icon="tint"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HistogramHeader;
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { interpolateCool } from "d3-scale-chromatic";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
|
||||||
|
import maybeScientific from "./maybeScientific";
|
||||||
|
import clamp from "./clamp";
|
||||||
|
|
||||||
|
const Histogram = ({
|
||||||
|
field,
|
||||||
|
fieldForId,
|
||||||
|
display,
|
||||||
|
histogram,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
onBrush,
|
||||||
|
onBrushEnd,
|
||||||
|
margin,
|
||||||
|
isColorBy,
|
||||||
|
selectionRange,
|
||||||
|
}) => {
|
||||||
|
const svgRef = useRef(null);
|
||||||
|
const [brush, setBrush] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/*
|
||||||
|
Create the d3 histogram
|
||||||
|
*/
|
||||||
|
const { marginLeft, marginRight, marginBottom, marginTop } = margin;
|
||||||
|
const { x, y, bins, binStart, binEnd, binWidth } = histogram;
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
|
||||||
|
/* Remove everything */
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
/* Set margins within the SVG */
|
||||||
|
const container = svg
|
||||||
|
.attr("width", width + marginLeft + marginRight)
|
||||||
|
.attr("height", height + marginTop + marginBottom)
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "histogram-container")
|
||||||
|
.attr("transform", `translate(${marginLeft},${marginTop})`);
|
||||||
|
|
||||||
|
const colorScale = d3
|
||||||
|
.scaleSequential(interpolateCool)
|
||||||
|
.domain([0, bins.length]);
|
||||||
|
|
||||||
|
const histogramScale = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain(x.domain())
|
||||||
|
.range([
|
||||||
|
colorScale.domain()[1],
|
||||||
|
colorScale.domain()[0],
|
||||||
|
]); /* we flip this to make colors dark if high in the color scale */
|
||||||
|
|
||||||
|
if (binWidth > 0) {
|
||||||
|
/* BINS */
|
||||||
|
container
|
||||||
|
.insert("g", "*")
|
||||||
|
.selectAll("rect")
|
||||||
|
.data(bins)
|
||||||
|
.enter()
|
||||||
|
.append("rect")
|
||||||
|
.attr("x", (d, i) => x(binStart(i)) + 1)
|
||||||
|
.attr("y", (d) => y(d))
|
||||||
|
.attr("width", (d, i) => x(binEnd(i)) - x(binStart(i)) - 1)
|
||||||
|
.attr("height", (d) => y(0) - y(d))
|
||||||
|
.style(
|
||||||
|
"fill",
|
||||||
|
isColorBy ? (d, i) => colorScale(histogramScale(binStart(i))) : "#bbb"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BRUSH
|
||||||
|
// Note the brushable area is bounded by the data on three sides, but goes down to cover the x-axis
|
||||||
|
const brushX = d3
|
||||||
|
.brushX()
|
||||||
|
.extent([
|
||||||
|
[x.range()[0], y.range()[1]],
|
||||||
|
[x.range()[1], marginTop + height + marginBottom],
|
||||||
|
])
|
||||||
|
/*
|
||||||
|
emit start so that the Undoable history can save an undo point
|
||||||
|
upon drag start, and ignore the subsequent intermediate drag events.
|
||||||
|
*/
|
||||||
|
.on("start", onBrush(field, x.invert, "start"))
|
||||||
|
.on("brush", onBrush(field, x.invert, "brush"))
|
||||||
|
.on("end", onBrushEnd(field, x.invert));
|
||||||
|
|
||||||
|
const brushXselection = container
|
||||||
|
.insert("g")
|
||||||
|
.attr("class", "brush")
|
||||||
|
.attr("data-testid", `${svgRef.current.dataset.testid}-brushable-area`)
|
||||||
|
.call(brushX);
|
||||||
|
|
||||||
|
/* X AXIS */
|
||||||
|
container
|
||||||
|
.insert("g")
|
||||||
|
.attr("class", "axis axis--x")
|
||||||
|
.attr("transform", `translate(0,${marginTop + height})`)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisBottom(x)
|
||||||
|
.ticks(4)
|
||||||
|
.tickFormat(d3.format(maybeScientific(x)))
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Y AXIS */
|
||||||
|
container
|
||||||
|
.insert("g")
|
||||||
|
.attr("class", "axis axis--y")
|
||||||
|
.attr("transform", `translate(${marginLeft + width},0)`)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisRight(y)
|
||||||
|
.ticks(3)
|
||||||
|
.tickFormat(
|
||||||
|
d3.format(
|
||||||
|
y.domain().some((n) => Math.abs(n) >= 10000) ? ".0e" : ","
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* axis style */
|
||||||
|
svg.selectAll(".axis text").style("fill", "rgb(80,80,80)");
|
||||||
|
svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)");
|
||||||
|
svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)");
|
||||||
|
|
||||||
|
setBrush({ brushX, brushXselection });
|
||||||
|
}, [histogram, isColorBy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/*
|
||||||
|
paint/update selection brush
|
||||||
|
*/
|
||||||
|
if (!brush) return;
|
||||||
|
const { brushX, brushXselection } = brush;
|
||||||
|
const selection = d3.brushSelection(brushXselection.node());
|
||||||
|
if (!selectionRange && selection) {
|
||||||
|
/* no active selection - clear brush */
|
||||||
|
brushXselection.call(brushX.move, null);
|
||||||
|
} else if (selectionRange) {
|
||||||
|
const { x, domain } = histogram;
|
||||||
|
const [min, max] = domain;
|
||||||
|
const x0 = x(clamp(selectionRange[0], [min, max]));
|
||||||
|
const x1 = x(clamp(selectionRange[1], [min, max]));
|
||||||
|
if (!selection) {
|
||||||
|
/* there is an active selection, but no brush - set the brush */
|
||||||
|
brushXselection.call(brushX.move, [x0, x1]);
|
||||||
|
} else {
|
||||||
|
/* there is an active selection and a brush - make sure they match */
|
||||||
|
const moveDeltaThreshold = 1;
|
||||||
|
const dX0 = Math.abs(x0 - selection[0]);
|
||||||
|
const dX1 = Math.abs(x1 - selection[1]);
|
||||||
|
/*
|
||||||
|
only update the brush if it is grossly incorrect,
|
||||||
|
as defined by the moveDeltaThreshold
|
||||||
|
*/
|
||||||
|
if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) {
|
||||||
|
brushXselection.call(brushX.move, [x0, x1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [brush, selectionRange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
style={{ display }}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
id={`histogram_${fieldForId}_svg`}
|
||||||
|
data-testclass="histogram-plot"
|
||||||
|
data-testid={`histogram-${field}-plot`}
|
||||||
|
ref={svgRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Histogram;
|
||||||
@@ -4,441 +4,20 @@ https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
|
|||||||
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
|
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
|
||||||
https://bl.ocks.org/mbostock/3019563
|
https://bl.ocks.org/mbostock/3019563
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
import React from "react";
|
||||||
import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
|
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
import { interpolateCool } from "d3-scale-chromatic";
|
|
||||||
import Async from "react-async";
|
import Async from "react-async";
|
||||||
import memoize from "memoize-one";
|
import memoize from "memoize-one";
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import actions from "../../actions";
|
import actions from "../../actions";
|
||||||
import { histogramContinuous } from "../../util/dataframe/histogram";
|
import { histogramContinuous } from "../../util/dataframe/histogram";
|
||||||
import { makeContinuousDimensionName } from "../../util/nameCreators";
|
import { makeContinuousDimensionName } from "../../util/nameCreators";
|
||||||
import significantDigits from "../../util/significantDigits";
|
import HistogramHeader from "./header";
|
||||||
|
import Histogram from "./histogram";
|
||||||
function clamp(val, rng) {
|
import HistogramFooter from "./footer";
|
||||||
return Math.max(Math.min(val, rng[1]), rng[0]);
|
import StillLoading from "./loading";
|
||||||
}
|
import ErrorLoading from "./error";
|
||||||
|
|
||||||
function maybeScientific(x) {
|
|
||||||
let format = ",";
|
|
||||||
const _ticks = x.ticks(4);
|
|
||||||
|
|
||||||
if (x.domain().some((n) => Math.abs(n) >= 10000)) {
|
|
||||||
/*
|
|
||||||
heuristic: if the last tick d3 wants to render has one significant
|
|
||||||
digit ie., 2000, render 2e+3, but if it's anything else ie., 42000000 render
|
|
||||||
4.20e+n
|
|
||||||
*/
|
|
||||||
format = significantDigits(_ticks[_ticks.length - 1]) === 1 ? ".0e" : ".2e";
|
|
||||||
}
|
|
||||||
|
|
||||||
return format;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StillLoading = ({ zebra, displayName }) => {
|
|
||||||
/*
|
|
||||||
Render a loading indicator for the field.
|
|
||||||
*/
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: globals.leftSidebarSectionPadding,
|
|
||||||
backgroundColor: zebra ? globals.lightestGrey : "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
justifyItems: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 30 }} />
|
|
||||||
<div style={{ display: "flex", alignSelf: "center" }}>
|
|
||||||
<span style={{ fontStyle: "italic" }}>{displayName}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button minimal loading intent="primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ErrorLoading = ({ displayName, error, zebra }) => {
|
|
||||||
console.log(error); // log to console as this is unexpected
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: globals.leftSidebarSectionPadding,
|
|
||||||
backgroundColor: zebra ? globals.lightestGrey : "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{`Failure loading ${displayName}`}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const HistogramFooter = React.memo(
|
|
||||||
({
|
|
||||||
displayName,
|
|
||||||
hideRanges,
|
|
||||||
rangeMin,
|
|
||||||
rangeMax,
|
|
||||||
rangeColorMin,
|
|
||||||
rangeColorMax,
|
|
||||||
logFoldChange,
|
|
||||||
pvalAdj,
|
|
||||||
}) => {
|
|
||||||
/*
|
|
||||||
Footer of each histogram. Will render range, title, and optionally
|
|
||||||
differential expression info.
|
|
||||||
|
|
||||||
Required props:
|
|
||||||
* displayName - the displayName, aka "n_genes", "FOXP2", etc.
|
|
||||||
* hideRanges - true/false, enables/disable rendering of ranges
|
|
||||||
* range - length two array, [min, max], containing the range values to display
|
|
||||||
* rangeColor - length two array, [mincolor, maxcolor], each a CSS color
|
|
||||||
* logFoldChange - lfc to display, optional.
|
|
||||||
* pValue - pValue to display, optional.
|
|
||||||
*/
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: hideRanges ? "center" : "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: rangeColorMin,
|
|
||||||
display: hideRanges ? "none" : "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
min {rangeMin.toPrecision(4)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
data-testclass="brushable-histogram-field-name"
|
|
||||||
style={{ fontStyle: "italic" }}
|
|
||||||
>
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: hideRanges ? "block" : "none" }}>
|
|
||||||
: {rangeMin}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: rangeColorMax,
|
|
||||||
display: hideRanges ? "none" : "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
max {rangeMax.toPrecision(4)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{logFoldChange && pvalAdj ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<strong>log fold change:</strong>
|
|
||||||
{` ${logFoldChange.toPrecision(4)}`}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 7,
|
|
||||||
padding: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>p-value (adj):</strong>
|
|
||||||
{pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const HistogramHeader = React.memo(
|
|
||||||
({
|
|
||||||
fieldId,
|
|
||||||
isColorBy,
|
|
||||||
onColorByClick,
|
|
||||||
onRemoveClick,
|
|
||||||
isScatterPlotX,
|
|
||||||
isScatterPlotY,
|
|
||||||
onScatterPlotXClick,
|
|
||||||
onScatterPlotYClick,
|
|
||||||
isObs,
|
|
||||||
}) => {
|
|
||||||
/*
|
|
||||||
Render the toolbar for the histogram. Props:
|
|
||||||
* fieldId - field identifier, used for various IDs
|
|
||||||
* isColorBy - true/false, is this the current color-by
|
|
||||||
* onColorByClick - color-by click handler
|
|
||||||
* onRemoveClick - optional handler for remove. Button will not render if not defined.
|
|
||||||
* isScatterPlotX - optional, true/false if currently the X scatterplot field
|
|
||||||
* isScatterPlotY - optional, true/false if currently the Y scatterplot field
|
|
||||||
* onScatterPlotXClick - optional, handler for scatterPlot X button.
|
|
||||||
* onScatterPlotYClick - optional, handler for scatterPlot X button.
|
|
||||||
|
|
||||||
Scatterplot controls will not render if either handler unspecified.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const memoizedColorByCallback = useCallback(
|
|
||||||
() => onColorByClick(fieldId, isObs),
|
|
||||||
[fieldId, isObs]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
paddingBottom: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{onScatterPlotXClick && onScatterPlotYClick ? (
|
|
||||||
<span>
|
|
||||||
<span
|
|
||||||
style={{ marginRight: 7 }}
|
|
||||||
className="bp3-icon-standard bp3-icon-scatter-plot"
|
|
||||||
/>
|
|
||||||
<ButtonGroup style={{ marginRight: 7 }}>
|
|
||||||
<Button
|
|
||||||
data-testid={`plot-x-${fieldId}`}
|
|
||||||
onClick={onScatterPlotXClick}
|
|
||||||
active={isScatterPlotX}
|
|
||||||
intent={isScatterPlotX ? "primary" : "none"}
|
|
||||||
>
|
|
||||||
plot x
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid={`plot-y-${fieldId}`}
|
|
||||||
onClick={onScatterPlotYClick}
|
|
||||||
active={isScatterPlotY}
|
|
||||||
intent={isScatterPlotY ? "primary" : "none"}
|
|
||||||
>
|
|
||||||
plot y
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{onRemoveClick ? (
|
|
||||||
<Button
|
|
||||||
minimal
|
|
||||||
onClick={onRemoveClick}
|
|
||||||
style={{
|
|
||||||
color: globals.blue,
|
|
||||||
cursor: "pointer",
|
|
||||||
marginLeft: 7,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
remove
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Tooltip
|
|
||||||
content="Use as color scale"
|
|
||||||
position="bottom"
|
|
||||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={memoizedColorByCallback}
|
|
||||||
active={isColorBy}
|
|
||||||
intent={isColorBy ? "primary" : "none"}
|
|
||||||
data-testclass="colorby"
|
|
||||||
data-testid={`colorby-${fieldId}`}
|
|
||||||
icon="tint"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const Histogram = ({
|
|
||||||
field,
|
|
||||||
fieldForId,
|
|
||||||
display,
|
|
||||||
histogram,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
onBrush,
|
|
||||||
onBrushEnd,
|
|
||||||
margin,
|
|
||||||
isColorBy,
|
|
||||||
selectionRange,
|
|
||||||
}) => {
|
|
||||||
const svgRef = useRef(null);
|
|
||||||
const [brush, setBrush] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
/*
|
|
||||||
Create the d3 histogram
|
|
||||||
*/
|
|
||||||
const { marginLeft, marginRight, marginBottom, marginTop } = margin;
|
|
||||||
const { x, y, bins, binStart, binEnd, binWidth } = histogram;
|
|
||||||
const svg = d3.select(svgRef.current);
|
|
||||||
|
|
||||||
/* Remove everything */
|
|
||||||
svg.selectAll("*").remove();
|
|
||||||
|
|
||||||
/* Set margins within the SVG */
|
|
||||||
const container = svg
|
|
||||||
.attr("width", width + marginLeft + marginRight)
|
|
||||||
.attr("height", height + marginTop + marginBottom)
|
|
||||||
.append("g")
|
|
||||||
.attr("class", "histogram-container")
|
|
||||||
.attr("transform", `translate(${marginLeft},${marginTop})`);
|
|
||||||
|
|
||||||
const colorScale = d3
|
|
||||||
.scaleSequential(interpolateCool)
|
|
||||||
.domain([0, bins.length]);
|
|
||||||
|
|
||||||
const histogramScale = d3
|
|
||||||
.scaleLinear()
|
|
||||||
.domain(x.domain())
|
|
||||||
.range([
|
|
||||||
colorScale.domain()[1],
|
|
||||||
colorScale.domain()[0],
|
|
||||||
]); /* we flip this to make colors dark if high in the color scale */
|
|
||||||
|
|
||||||
if (binWidth > 0) {
|
|
||||||
/* BINS */
|
|
||||||
container
|
|
||||||
.insert("g", "*")
|
|
||||||
.selectAll("rect")
|
|
||||||
.data(bins)
|
|
||||||
.enter()
|
|
||||||
.append("rect")
|
|
||||||
.attr("x", (d, i) => x(binStart(i)) + 1)
|
|
||||||
.attr("y", (d) => y(d))
|
|
||||||
.attr("width", (d, i) => x(binEnd(i)) - x(binStart(i)) - 1)
|
|
||||||
.attr("height", (d) => y(0) - y(d))
|
|
||||||
.style(
|
|
||||||
"fill",
|
|
||||||
isColorBy ? (d, i) => colorScale(histogramScale(binStart(i))) : "#bbb"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// BRUSH
|
|
||||||
// Note the brushable area is bounded by the data on three sides, but goes down to cover the x-axis
|
|
||||||
const brushX = d3
|
|
||||||
.brushX()
|
|
||||||
.extent([
|
|
||||||
[x.range()[0], y.range()[1]],
|
|
||||||
[x.range()[1], marginTop + height + marginBottom],
|
|
||||||
])
|
|
||||||
/*
|
|
||||||
emit start so that the Undoable history can save an undo point
|
|
||||||
upon drag start, and ignore the subsequent intermediate drag events.
|
|
||||||
*/
|
|
||||||
.on("start", onBrush(field, x.invert, "start"))
|
|
||||||
.on("brush", onBrush(field, x.invert, "brush"))
|
|
||||||
.on("end", onBrushEnd(field, x.invert));
|
|
||||||
|
|
||||||
const brushXselection = container
|
|
||||||
.insert("g")
|
|
||||||
.attr("class", "brush")
|
|
||||||
.attr("data-testid", `${svgRef.current.dataset.testid}-brushable-area`)
|
|
||||||
.call(brushX);
|
|
||||||
|
|
||||||
/* X AXIS */
|
|
||||||
container
|
|
||||||
.insert("g")
|
|
||||||
.attr("class", "axis axis--x")
|
|
||||||
.attr("transform", `translate(0,${marginTop + height})`)
|
|
||||||
.call(
|
|
||||||
d3
|
|
||||||
.axisBottom(x)
|
|
||||||
.ticks(4)
|
|
||||||
.tickFormat(d3.format(maybeScientific(x)))
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Y AXIS */
|
|
||||||
container
|
|
||||||
.insert("g")
|
|
||||||
.attr("class", "axis axis--y")
|
|
||||||
.attr("transform", `translate(${marginLeft + width},0)`)
|
|
||||||
.call(
|
|
||||||
d3
|
|
||||||
.axisRight(y)
|
|
||||||
.ticks(3)
|
|
||||||
.tickFormat(
|
|
||||||
d3.format(
|
|
||||||
y.domain().some((n) => Math.abs(n) >= 10000) ? ".0e" : ","
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/* axis style */
|
|
||||||
svg.selectAll(".axis text").style("fill", "rgb(80,80,80)");
|
|
||||||
svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)");
|
|
||||||
svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)");
|
|
||||||
|
|
||||||
setBrush({ brushX, brushXselection });
|
|
||||||
}, [histogram, isColorBy]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
/*
|
|
||||||
paint/update selection brush
|
|
||||||
*/
|
|
||||||
if (!brush) return;
|
|
||||||
const { brushX, brushXselection } = brush;
|
|
||||||
const selection = d3.brushSelection(brushXselection.node());
|
|
||||||
if (!selectionRange && selection) {
|
|
||||||
/* no active selection - clear brush */
|
|
||||||
brushXselection.call(brushX.move, null);
|
|
||||||
} else if (selectionRange) {
|
|
||||||
const { x, domain } = histogram;
|
|
||||||
const [min, max] = domain;
|
|
||||||
const x0 = x(clamp(selectionRange[0], [min, max]));
|
|
||||||
const x1 = x(clamp(selectionRange[1], [min, max]));
|
|
||||||
if (!selection) {
|
|
||||||
/* there is an active selection, but no brush - set the brush */
|
|
||||||
brushXselection.call(brushX.move, [x0, x1]);
|
|
||||||
} else {
|
|
||||||
/* there is an active selection and a brush - make sure they match */
|
|
||||||
const moveDeltaThreshold = 1;
|
|
||||||
const dX0 = Math.abs(x0 - selection[0]);
|
|
||||||
const dX1 = Math.abs(x1 - selection[1]);
|
|
||||||
/*
|
|
||||||
only update the brush if it is grossly incorrect,
|
|
||||||
as defined by the moveDeltaThreshold
|
|
||||||
*/
|
|
||||||
if (dX0 > moveDeltaThreshold || dX1 > moveDeltaThreshold) {
|
|
||||||
brushXselection.call(brushX.move, [x0, x1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [brush, selectionRange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
style={{ display }}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
id={`histogram_${fieldForId}_svg`}
|
|
||||||
data-testclass="histogram-plot"
|
|
||||||
data-testid={`histogram-${field}-plot`}
|
|
||||||
ref={svgRef}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
@connect((state, ownProps) => {
|
@connect((state, ownProps) => {
|
||||||
const { isObs, isUserDefined, isDiffExp, field } = ownProps;
|
const { isObs, isUserDefined, isDiffExp, field } = ownProps;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@blueprintjs/core";
|
||||||
|
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
|
const StillLoading = ({ zebra, displayName }) => {
|
||||||
|
/*
|
||||||
|
Render a loading indicator for the field.
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: globals.leftSidebarSectionPadding,
|
||||||
|
backgroundColor: zebra ? globals.lightestGrey : "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
justifyItems: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 30 }} />
|
||||||
|
<div style={{ display: "flex", alignSelf: "center" }}>
|
||||||
|
<span style={{ fontStyle: "italic" }}>{displayName}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button minimal loading intent="primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StillLoading;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import significantDigits from "../../util/significantDigits";
|
||||||
|
|
||||||
|
export default function maybeScientific(x) {
|
||||||
|
let format = ",";
|
||||||
|
const _ticks = x.ticks(4);
|
||||||
|
|
||||||
|
if (x.domain().some((n) => Math.abs(n) >= 10000)) {
|
||||||
|
/*
|
||||||
|
heuristic: if the last tick d3 wants to render has one significant
|
||||||
|
digit ie., 2000, render 2e+3, but if it's anything else ie., 42000000 render
|
||||||
|
4.20e+n
|
||||||
|
*/
|
||||||
|
format = significantDigits(_ticks[_ticks.length - 1]) === 1 ? ".0e" : ".2e";
|
||||||
|
}
|
||||||
|
|
||||||
|
return format;
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import { connect } from "react-redux";
|
|||||||
|
|
||||||
import { AnchorButton, Icon } from "@blueprintjs/core";
|
import { AnchorButton, Icon } from "@blueprintjs/core";
|
||||||
import Truncate from "../util/truncate";
|
import Truncate from "../util/truncate";
|
||||||
|
import HistogramBrush from "../brushableHistogram";
|
||||||
|
|
||||||
import TestMiniHisto from "./test_miniHisto";
|
// import TestMiniHisto from "./test_miniHisto";
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import GeneMenus from "./menus/geneMenus";
|
import GeneMenus from "./menus/geneMenus";
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ class Gene extends React.Component {
|
|||||||
{gene}
|
{gene}
|
||||||
</span>
|
</span>
|
||||||
</Truncate>
|
</Truncate>
|
||||||
<TestMiniHisto />
|
{/* <TestMiniHisto /> */}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ class Gene extends React.Component {
|
|||||||
icon={<Icon icon="maximize" iconSize={10} />}
|
icon={<Icon icon="maximize" iconSize={10} />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<HistogramBrush field={gene} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { AnchorButton, Icon } from "@blueprintjs/core";
|
import { AnchorButton, Icon, Tooltip, Position } from "@blueprintjs/core";
|
||||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||||
import actions from "../../actions";
|
import actions from "../../actions";
|
||||||
import Gene from "./gene";
|
import Gene from "./gene";
|
||||||
import { memoize } from "../../util/dataframe/util";
|
import { memoize } from "../../util/dataframe/util";
|
||||||
import Truncate from "../util/truncate";
|
import Truncate from "../util/truncate";
|
||||||
import TestMiniHisto from "./test_miniHisto";
|
// import TestMiniHisto from "./test_miniHisto";
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import GenesetMenus from "./menus/genesetMenus";
|
// import GenesetMenus from "./menus/genesetMenus";
|
||||||
|
|
||||||
@connect((state, ownProps) => {
|
@connect((state, ownProps) => {
|
||||||
return {
|
return {
|
||||||
@@ -140,7 +140,7 @@ class GeneSet extends React.Component {
|
|||||||
style={{
|
style={{
|
||||||
maxWidth:
|
maxWidth:
|
||||||
globals.leftSidebarWidth -
|
globals.leftSidebarWidth -
|
||||||
240 /* todo_genesets this magic number determines how much of a long geneset name we see, and will be tweaked as we build */,
|
150 /* todo_genesets this magic number determines how much of a long geneset name we see, and will be tweaked as we build */,
|
||||||
}}
|
}}
|
||||||
data-testid={`${setName}:geneset-label`}
|
data-testid={`${setName}:geneset-label`}
|
||||||
>
|
>
|
||||||
@@ -149,16 +149,28 @@ class GeneSet extends React.Component {
|
|||||||
</Truncate>
|
</Truncate>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<TestMiniHisto />
|
{/* <TestMiniHisto /> */}
|
||||||
<GenesetMenus genesetsEditable geneset={setName} />
|
{/* <GenesetMenus genesetsEditable geneset={setName} /> */}
|
||||||
<AnchorButton
|
<Tooltip
|
||||||
data-testclass="colorby"
|
content="Color by geneset"
|
||||||
data-testid={`colorby-${setName}`}
|
position={Position.LEFT}
|
||||||
onClick={this.onColorChangeClick}
|
usePortal
|
||||||
active={isColorAccessor}
|
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||||
intent={isColorAccessor ? "primary" : "none"}
|
modifiers={{
|
||||||
icon={<Icon icon="tint" iconSize={16} />}
|
preventOverflow: { enabled: false },
|
||||||
/>
|
hide: { enabled: false },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnchorButton
|
||||||
|
disabled
|
||||||
|
data-testclass="colorby"
|
||||||
|
data-testid={`colorby-${setName}`}
|
||||||
|
onClick={this.onColorChangeClick}
|
||||||
|
active={isColorAccessor}
|
||||||
|
intent={isColorAccessor ? "primary" : "none"}
|
||||||
|
icon={<Icon icon="tint" iconSize={16} />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user