This commit is contained in:
Colin Megill
2020-10-06 17:32:05 -07:00
parent 86d369e63e
commit 6f23f1e7e5
5 changed files with 160 additions and 277 deletions
@@ -10,6 +10,7 @@ const HistogramFooter = React.memo(
rangeColorMax, rangeColorMax,
logFoldChange, logFoldChange,
pvalAdj, pvalAdj,
isObs,
}) => { }) => {
/* /*
Footer of each histogram. Will render range, title, and optionally Footer of each histogram. Will render range, title, and optionally
@@ -43,7 +44,7 @@ const HistogramFooter = React.memo(
data-testclass="brushable-histogram-field-name" data-testclass="brushable-histogram-field-name"
style={{ fontStyle: "italic" }} style={{ fontStyle: "italic" }}
> >
{displayName} {isObs ? displayName : null}
</span> </span>
<div style={{ display: hideRanges ? "block" : "none" }}> <div style={{ display: hideRanges ? "block" : "none" }}>
: {rangeMin} : {rangeMin}
@@ -75,62 +75,64 @@ const Histogram = ({
); );
} }
// BRUSH if (!mini) {
// Note the brushable area is bounded by the data on three sides, but goes down to cover the x-axis // BRUSH
const brushX = d3 // Note the brushable area is bounded by the data on three sides, but goes down to cover the x-axis
.brushX() const brushX = d3
.extent([ .brushX()
[x.range()[0], y.range()[1]], .extent([
[x.range()[1], marginTop + height + marginBottom], [x.range()[0], y.range()[1]],
]) [x.range()[1], marginTop + height + marginBottom],
/* ])
/*
emit start so that the Undoable history can save an undo point emit start so that the Undoable history can save an undo point
upon drag start, and ignore the subsequent intermediate drag events. upon drag start, and ignore the subsequent intermediate drag events.
*/ */
.on("start", onBrush(field, x.invert, "start")) .on("start", onBrush(field, x.invert, "start"))
.on("brush", onBrush(field, x.invert, "brush")) .on("brush", onBrush(field, x.invert, "brush"))
.on("end", onBrushEnd(field, x.invert)); .on("end", onBrushEnd(field, x.invert));
const brushXselection = container const brushXselection = container
.insert("g") .insert("g")
.attr("class", "brush") .attr("class", "brush")
.attr("data-testid", `${svgRef.current.dataset.testid}-brushable-area`) .attr("data-testid", `${svgRef.current.dataset.testid}-brushable-area`)
.call(brushX); .call(brushX);
/* X AXIS */ /* X AXIS */
container container
.insert("g") .insert("g")
.attr("class", "axis axis--x") .attr("class", "axis axis--x")
.attr("transform", `translate(0,${marginTop + height})`) .attr("transform", `translate(0,${marginTop + height})`)
.call( .call(
d3 d3
.axisBottom(x) .axisBottom(x)
.ticks(4) .ticks(4)
.tickFormat(d3.format(maybeScientific(x))) .tickFormat(d3.format(maybeScientific(x)))
); );
/* Y AXIS */ /* Y AXIS */
container container
.insert("g") .insert("g")
.attr("class", "axis axis--y") .attr("class", "axis axis--y")
.attr("transform", `translate(${marginLeft + width},0)`) .attr("transform", `translate(${marginLeft + width},0)`)
.call( .call(
d3 d3
.axisRight(y) .axisRight(y)
.ticks(3) .ticks(3)
.tickFormat( .tickFormat(
d3.format( d3.format(
y.domain().some((n) => Math.abs(n) >= 10000) ? ".0e" : "," y.domain().some((n) => Math.abs(n) >= 10000) ? ".0e" : ","
)
) )
) );
);
/* axis style */ /* axis style */
svg.selectAll(".axis text").style("fill", "rgb(80,80,80)"); svg.selectAll(".axis text").style("fill", "rgb(80,80,80)");
svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)"); svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)");
svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)"); svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)");
setBrush({ brushX, brushXselection }); setBrush({ brushX, brushXselection });
}
}, [histogram, isColorBy]); }, [histogram, isColorBy]);
useEffect(() => { useEffect(() => {
@@ -1,178 +0,0 @@
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;
@@ -54,11 +54,6 @@ class HistogramBrush extends React.PureComponent {
const marginBottom = 25; // space for X axis & labels const marginBottom = 25; // space for X axis & labels
const marginTop = 3; const marginTop = 3;
const marginLeftMini = 10; // Space for 0 tick label on X axis
const marginRightMini = 54; // space for Y axis & labels
const marginBottomMini = 25; // space for X axis & labels
const marginTopMini = 3;
this.state = { this.state = {
margin: { margin: {
marginLeft, marginLeft,
@@ -69,13 +64,13 @@ class HistogramBrush extends React.PureComponent {
width: 340 - marginLeft - marginRight, width: 340 - marginLeft - marginRight,
height: 135 - marginTop - marginBottom, height: 135 - marginTop - marginBottom,
marginMini: { marginMini: {
marginLeftMini, marginLeft: 0, // Space for 0 tick label on X axis
marginRightMini, marginRight: 0, // space for Y axis & labels
marginBottomMini, marginBottom: 0, // space for X axis & labels
marginTopMini, marginTop: 0,
}, },
widthMini: 340 - marginLeft - marginRight, widthMini: 120,
heightMini: 135 - marginTop - marginBottom, heightMini: 15,
}; };
} }
@@ -271,16 +266,20 @@ class HistogramBrush extends React.PureComponent {
// eslint-disable-next-line class-methods-use-this -- instance method allows for memoization per annotation // eslint-disable-next-line class-methods-use-this -- instance method allows for memoization per annotation
calcHistogramCache(col, margin, width, height) { calcHistogramCache(col, margin, width, height) {
/* make this more structured and doing a forEach */
/* /*
recalculate expensive stuff, notably bins, summaries, etc. recalculate expensive stuff, notably bins, summaries, etc.
*/ */
const histogramCache = {}; const histogramCache = {}; /* maybe change this so that it computes ... */
const summary = col.summarize(); const summary = col.summarize(); /* this is memoized, so it's free the second time you call it */
const { min: domainMin, max: domainMax } = summary; const { min: domainMin, max: domainMax } = summary;
const numBins = 40; const numBins = 40;
const { marginTop, marginLeft } = margin; const { marginTop, marginLeft } = margin; /* changes with mini */
histogramCache.domain = [domainMin, domainMax]; histogramCache.domain = [
domainMin,
domainMax,
]; /* doesn't change with mini */
histogramCache.x = d3 histogramCache.x = d3
.scaleLinear() .scaleLinear()
@@ -290,7 +289,7 @@ class HistogramBrush extends React.PureComponent {
histogramCache.bins = histogramContinuous(col, numBins, [ histogramCache.bins = histogramContinuous(col, numBins, [
domainMin, domainMin,
domainMax, domainMax,
]); ]); /* memoized */
histogramCache.binWidth = (domainMax - domainMin) / numBins; histogramCache.binWidth = (domainMax - domainMin) / numBins;
histogramCache.binStart = (i) => domainMin + i * histogramCache.binWidth; histogramCache.binStart = (i) => domainMin + i * histogramCache.binWidth;
@@ -377,11 +376,11 @@ class HistogramBrush extends React.PureComponent {
: "histogram-continuous-metadata" : "histogram-continuous-metadata"
} }
style={{ style={{
padding: globals.leftSidebarSectionPadding, padding: mini ? 0 : globals.leftSidebarSectionPadding,
backgroundColor: zebra ? globals.lightestGrey : "white", backgroundColor: zebra ? globals.lightestGrey : "white",
}} }}
> >
{!mini ? ( {!mini && isObs ? (
<HistogramHeader <HistogramHeader
fieldId={field} fieldId={field}
isColorBy={isColorAccessor} isColorBy={isColorAccessor}
@@ -416,6 +415,7 @@ class HistogramBrush extends React.PureComponent {
/> />
{!mini ? ( {!mini ? (
<HistogramFooter <HistogramFooter
isObs={isObs}
displayName={field} displayName={field}
hideRanges={asyncProps.isSingleValue} hideRanges={asyncProps.isSingleValue}
rangeMin={asyncProps.unclippedRange[0]} rangeMin={asyncProps.unclippedRange[0]}
+91 -33
View File
@@ -11,10 +11,18 @@ 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 actions from "../../actions";
// import GeneMenus from "./menus/geneMenus"; // import GeneMenus from "./menus/geneMenus";
@connect(() => { @connect((state, ownProps) => {
return {}; const { gene } = ownProps;
return {
isColorAccessor: state.colors.colorAccessor === gene,
isScatterplotXXaccessor: state.controls.scatterplotXXaccessor === gene,
isScatterplotYYaccessor: state.controls.scatterplotYYaccessor === gene,
};
}) })
class Gene extends React.Component { class Gene extends React.Component {
constructor(props) { constructor(props) {
@@ -26,10 +34,7 @@ class Gene extends React.Component {
onColorChangeClick = () => { onColorChangeClick = () => {
const { dispatch, gene } = this.props; const { dispatch, gene } = this.props;
dispatch({ dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(gene));
type: "color by expression",
colorAccessor: gene,
});
}; };
handleGeneExpandClick = () => { handleGeneExpandClick = () => {
@@ -37,26 +42,42 @@ class Gene extends React.Component {
this.setState({ geneIsExpanded: !geneIsExpanded }); this.setState({ geneIsExpanded: !geneIsExpanded });
}; };
handleSetGeneAsScatterplotX = () => {
const { dispatch, gene } = this.props;
dispatch({
type: "set scatterplot x",
data: gene,
});
};
handleSetGeneAsScatterplotY = () => {
const { dispatch, gene } = this.props;
dispatch({
type: "set scatterplot y",
data: gene,
});
};
render() { render() {
const { gene } = this.props; const {
gene,
isColorAccessor,
isScatterplotXXaccessor,
isScatterplotYYaccessor,
} = this.props;
const { geneIsExpanded } = this.state; const { geneIsExpanded } = this.state;
return ( return (
<div> <div>
<div <div
style={{ style={{
marginLeft: 15, marginLeft: 15,
marginRight: 0,
marginTop: 2,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}} }}
> >
<Icon
icon="drag-handle-horizontal"
iconSize={12}
style={{
marginRight: 10,
cursor: "grab",
position: "relative",
top: -2,
}}
/>
<span <span
role="menuitem" role="menuitem"
tabIndex="0" tabIndex="0"
@@ -65,8 +86,20 @@ class Gene extends React.Component {
onKeyPress={/* todo_genesets */ () => {}} onKeyPress={/* todo_genesets */ () => {}}
style={{ style={{
cursor: "pointer", cursor: "pointer",
display: "flex",
justifyContent: "space-between",
}} }}
> >
<Icon
icon="drag-handle-horizontal"
iconSize={12}
style={{
marginRight: 7,
cursor: "grab",
position: "relative",
top: 3,
}}
/>
<Truncate> <Truncate>
<span <span
style={{ style={{
@@ -79,22 +112,58 @@ class Gene extends React.Component {
{gene} {gene}
</span> </span>
</Truncate> </Truncate>
{!geneIsExpanded ? (
<HistogramBrush isUserDefined field={gene} mini />
) : null}
</span>
<span>
<AnchorButton
minimal
small
data-testid={`plot-x-${gene}`}
onClick={this.handleSetGeneAsScatterplotX}
active={isScatterplotXXaccessor}
intent={isScatterplotXXaccessor ? "primary" : "none"}
style={{ fontWeight: 700, marginRight: 2 }}
>
x
</AnchorButton>
<AnchorButton
minimal
small
data-testid={`plot-y-${gene}`}
onClick={this.handleSetGeneAsScatterplotY}
active={isScatterplotYYaccessor}
intent={isScatterplotYYaccessor ? "primary" : "none"}
style={{ fontWeight: 700, marginRight: 2 }}
>
y
</AnchorButton>
<AnchorButton <AnchorButton
minimal minimal
small small
data-testclass="maximize" data-testclass="maximize"
data-testid={`maximize-${gene}`} data-testid={`maximize-${gene}`}
onClick={this.handleGeneExpandClick} onClick={this.handleGeneExpandClick}
active={false /* todo gene sets */} active={geneIsExpanded}
intent="none" intent="none"
icon={<Icon icon="maximize" iconSize={10} />} icon={<Icon icon="maximize" iconSize={10} />}
style={{ marginRight: 2 }}
/>
<AnchorButton
minimal
small
data-testclass="colorby"
data-testid={`colorby-${gene}`}
onClick={this.onColorChangeClick}
active={isColorAccessor}
intent={isColorAccessor ? "primary" : "none"}
icon={<Icon icon="tint" iconSize={12} />}
/> />
{/* <TestMiniHisto /> */}
</span> </span>
{/* <TestMiniHisto /> */}
</div> </div>
{geneIsExpanded ? <HistogramBrush isUserDefined field={gene} /> : null}
<HistogramBrush isUserDefined field={gene} mini={!geneIsExpanded} />
{/* <GeneMenus genesetsEditable gene={gene} /> */} {/* <GeneMenus genesetsEditable gene={gene} /> */}
</div> </div>
); );
@@ -103,17 +172,6 @@ class Gene extends React.Component {
export default Gene; export default Gene;
// <AnchorButton
// minimal
// small
// data-testclass="colorby"
// data-testid={`colorby-${gene}`}
// onClick={/* todo gene sets */ () => {}}
// active={false /* todo gene sets */}
// intent="none"
// icon={<Icon icon="tint" iconSize={12} />}
// />
// {geneIsExpanded ? ( // {geneIsExpanded ? (
// <FaChevronDown // <FaChevronDown
// data-testclass="gene-expand-is-expanded" // data-testclass="gene-expand-is-expanded"