Files
cellxgene/client/src/components/brushableHistogram/index.js
T
Matt Weiden 1f94c71c22 Use sci notation for axis ticks abs(tick) >= 10000 (#1363)
Fixes https://github.com/chanzuckerberg/cellxgene/issues/1349

For more information see https://github.com/d3/d3-format

Note that does not _fully_ fix the issue described in #1349, but rather
makes the formatting issue far less likely. It is _still_ possible for
this to occur if the difference between two ticks in axes happes in the
a significant digit cropped by the scientific notation format
2020-04-08 09:45:32 -07:00

560 lines
17 KiB
JavaScript

/*
https://bl.ocks.org/mbostock/4341954
https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
https://bl.ocks.org/mbostock/3019563
*/
// jshint esversion: 6
import React from "react";
import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core";
import { connect } from "react-redux";
import * as d3 from "d3";
import memoize from "memoize-one";
import { interpolateCool } from "d3-scale-chromatic";
import * as globals from "../../globals";
import actions from "../../actions";
import { histogramContinuous } from "../../util/dataframe/histogram";
import { makeContinuousDimensionName } from "../../util/nameCreators";
@connect((state, ownProps) => {
const { isObs, isUserDefined, isDiffExp, field } = ownProps;
const myName = makeContinuousDimensionName(
{ isObs, isUserDefined, isDiffExp },
field
);
return {
world: state.world,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
continuousSelectionRange: state.continuousSelection[myName],
colorAccessor: state.colors.colorAccessor
};
})
class HistogramBrush extends React.PureComponent {
static getColumn(world, field, clipped = true) {
/*
Return the underlying Dataframe column for our field. By default,
returns the clipped column. If clipped===false, will return the
unclipped column.
*/
const obsAnnotations = clipped
? world.obsAnnotations
: world.unclipped.obsAnnotations;
const varData = clipped ? world.varData : world.unclipped.varData;
if (obsAnnotations.hasCol(field)) {
return obsAnnotations.col(field);
}
return varData.col(field);
}
calcHistogramCache = memoize(col => {
/*
recalculate expensive stuff, notably bins, summaries, etc.
*/
const histogramCache = {};
const summary = col.summarize();
const { min: domainMin, max: domainMax } = summary;
const numBins = 40;
histogramCache.x = d3
.scaleLinear()
.domain([domainMin, domainMax])
.range([this.marginLeft, this.marginLeft + this.width]);
histogramCache.bins = histogramContinuous(col, numBins, [domainMin, domainMax]);
histogramCache.binWidth = (domainMax - domainMin) / numBins;
histogramCache.binStart = i => domainMin + i * histogramCache.binWidth;
histogramCache.binEnd = i => domainMin + (i + 1) * histogramCache.binWidth;
const yMax = histogramCache.bins.reduce((l, r) => l > r ? l : r);
histogramCache.y = d3
.scaleLinear()
.domain([0, yMax])
.range([this.marginTop + this.height, this.marginTop]);
return histogramCache;
});
constructor(props) {
super(props);
this.marginLeft = 12; // Space for 0 tick label on X axis
this.marginRight = 52; // space for Y axis & labels
this.marginBottom = 25; // space for X axis & labels
this.marginTop = 3;
this.width = 340 - this.marginLeft - this.marginRight;
this.height = 135 - this.marginTop - this.marginBottom;
}
componentDidMount() {
const { field, colorAccessor } = this.props;
this.renderHistogram(this._histogram, field, colorAccessor);
}
componentDidUpdate(prevProps) {
const { field, world, continuousSelectionRange: range, colorAccessor } = this.props;
const { x } = this._histogram;
let { brushX, brushXselection } = this.state;
const dfColumn = HistogramBrush.getColumn(world, field);
const oldDfColumn = HistogramBrush.getColumn(prevProps.world, prevProps.field);
const rangeChanged = range !== prevProps.continuousSelectionRange;
const dfChanged = dfColumn !== oldDfColumn;
const colorSelectionChanged = prevProps.colorAccessor !== colorAccessor;
if (dfChanged || colorSelectionChanged) {
({brushX, brushXselection} = this.renderHistogram(this._histogram, field, colorAccessor));
}
/*
if the selection has changed, ensure that the brush correctly reflects
the underlying selection.
*/
if ((dfChanged || rangeChanged || colorSelectionChanged) && brushXselection) {
const selection = d3.brushSelection(brushXselection.node());
if (!range && selection) {
/* no active selection - clear brush */
brushXselection.call(brushX.move, null);
} else if (range && !selection) {
/* there is an active selection, but no brush - set the brush */
const x0 = x(range[0]);
const x1 = x(range[1]);
brushXselection.call(brushX.move, [x0, x1]);
} else if (range && selection) {
/* there is an active selection and a brush - make sure they match */
const moveDeltaThreshold = 1;
const x0 = x(range[0]);
const x1 = x(range[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]);
}
}
}
}
onBrush(selection, x, eventType) {
const type = `continuous metadata histogram ${eventType}`;
return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
// ignore programmatically generated events
if (!d3.event.sourceEvent) return;
// ignore cascading events, which are programmatically generated
if (d3.event.sourceEvent.sourceEvent) return;
if (d3.event.selection) {
dispatch({
type,
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
},
range: [x(d3.event.selection[0]), x(d3.event.selection[1])]
});
} else {
dispatch({
type,
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
},
range: null
});
}
};
}
onBrushEnd(selection, x) {
return () => {
const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props;
const minAllowedBrushSize = 10;
const smallAmountToAvoidInfiniteLoop = 0.1;
// ignore programmatically generated events
if (!d3.event.sourceEvent) return;
// ignore cascading events, which are programmatically generated
if (d3.event.sourceEvent.sourceEvent) return;
if (d3.event.selection) {
let _range;
if (
d3.event.selection[1] - d3.event.selection[0] >
minAllowedBrushSize
) {
_range = [x(d3.event.selection[0]), x(d3.event.selection[1])];
} else {
/* the user selected range is too small and will be hidden #587, so take control of it procedurally */
/* https://stackoverflow.com/questions/12354729/d3-js-limit-size-of-brush */
const procedurallyResizedBrushWidth =
d3.event.selection[0] +
minAllowedBrushSize +
smallAmountToAvoidInfiniteLoop; //
_range = [x(d3.event.selection[0]), x(procedurallyResizedBrushWidth)];
}
dispatch({
type: "continuous metadata histogram end",
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
},
range: _range
});
} else {
dispatch({
type: "continuous metadata histogram cancel",
selection: field,
continuousNamespace: {
isObs,
isUserDefined,
isDiffExp
}
});
}
};
}
handleSetGeneAsScatterplotX = () => {
const { dispatch, field } = this.props;
dispatch({
type: "set scatterplot x",
data: field
});
};
handleSetGeneAsScatterplotY = () => {
const { dispatch, field } = this.props;
dispatch({
type: "set scatterplot y",
data: field
});
};
handleColorAction = () => {
const { dispatch, field, world, ranges } = this.props;
if (world.obsAnnotations.hasCol(field)) {
dispatch({
type: "color by continuous metadata",
colorAccessor: field,
rangeForColorAccessor: ranges
});
} else if (world.varData.hasCol(field)) {
dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(field));
}
};
removeHistogram = () => {
const {
dispatch,
field,
colorAccessor,
scatterplotXXaccessor,
scatterplotYYaccessor
} = this.props;
dispatch({
type: "clear user defined gene",
data: field
});
if (field === colorAccessor) {
dispatch({
type: "reset colorscale"
});
}
if (field === scatterplotXXaccessor) {
dispatch({
type: "set scatterplot x",
data: null
});
}
if (field === scatterplotYYaccessor) {
dispatch({
type: "set scatterplot y",
data: null
});
}
};
drawHistogram(svgRef) {
const { field, world } = this.props;
const col = HistogramBrush.getColumn(world, field);
this._histogram = {
...this.calcHistogramCache(col),
svgRef
};
}
renderHistogram(histogram, field, colorAccessor) {
const { x, y, bins, svgRef, binStart, binEnd, binWidth } = histogram;
const svg = d3.select(svgRef);
/* Remove everything */
svg.selectAll("*").remove();
/* Set margins within the SVG */
const container = svg
.attr("width", this.width + this.marginLeft + this.marginRight)
.attr("height", this.height + this.marginTop + this.marginBottom)
.append("g")
.attr("class", "histogram-container")
.attr("transform", `translate(${this.marginLeft},${this.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", colorAccessor === field ? (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], this.marginTop + this.height + this.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", this.onBrush(field, x.invert, "start").bind(this))
.on("brush", this.onBrush(field, x.invert, "brush").bind(this))
.on("end", this.onBrushEnd(field, x.invert).bind(this));
const brushXselection = container
.insert("g")
.attr("class", "brush")
.attr("data-testid", `${svgRef.dataset.testid}-brushable-area`)
.call(brushX);
/* X AXIS */
container
.insert("g")
.attr("class", "axis axis--x")
.attr("transform", `translate(0,${this.marginTop + this.height})`)
.call(
d3
.axisBottom(x)
.ticks(4)
.tickFormat(d3.format(x.domain().some(n => Math.abs(n) >= 10000) ? ".2e" : ","))
);
/* Y AXIS */
container
.insert("g")
.attr("class", "axis axis--y")
.attr("transform", `translate(${this.marginLeft + this.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)");
const newState = { brushX, brushXselection };
this.setState(newState);
return newState;
}
render() {
const {
field,
world,
colorAccessor,
isUserDefined,
isDiffExp,
logFoldChange,
pvalAdj,
scatterplotXXaccessor,
scatterplotYYaccessor,
zebra
} = this.props;
const fieldForId = field.replace(/\s/g, "_");
const {
min: unclippedRangeMin,
max: unclippedRangeMax
} = HistogramBrush.getColumn(world, field, false).summarize();
const unclippedRangeMinColor =
world.clipQuantiles.min === 0 ? "#bbb" : globals.blue;
const unclippedRangeMaxColor =
world.clipQuantiles.max === 1 ? "#bbb" : globals.blue;
return (
<div
id={`histogram_${fieldForId}`}
data-testid={`histogram-${field}`}
data-testclass={
isDiffExp
? "histogram-diffexp"
: isUserDefined
? "histogram-user-gene"
: "histogram-continuous-metadata"
}
style={{
padding: globals.leftSidebarSectionPadding,
backgroundColor: zebra ? globals.lightestGrey : "white"
}}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
paddingBottom: "8px"
}}
>
{isDiffExp || isUserDefined ? (
<span>
<span
style={{ marginRight: 7 }}
className="bp3-icon-standard bp3-icon-scatter-plot"
/>
<ButtonGroup style={{ marginRight: 7 }}>
<Button
data-testid={`plot-x-${field}`}
onClick={this.handleSetGeneAsScatterplotX}
active={scatterplotXXaccessor === field}
intent={scatterplotXXaccessor === field ? "primary" : "none"}
>
plot x
</Button>
<Button
data-testid={`plot-y-${field}`}
onClick={this.handleSetGeneAsScatterplotY}
active={scatterplotYYaccessor === field}
intent={scatterplotYYaccessor === field ? "primary" : "none"}
>
plot y
</Button>
</ButtonGroup>
</span>
) : null}
{isUserDefined ? (
<Button
minimal
onClick={this.removeHistogram}
style={{
color: globals.blue,
cursor: "pointer",
marginLeft: 7
}}
>
remove
</Button>
) : null}
<Tooltip
content="Use as color scale"
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Button
onClick={this.handleColorAction}
active={colorAccessor === field}
intent={colorAccessor === field ? "primary" : "none"}
data-testclass="colorby"
data-testid={`colorby-${field}`}
icon="tint"
/>
</Tooltip>
</div>
<svg
width={this.width}
height={this.height}
id={`histogram_${fieldForId}_svg`}
data-testclass="histogram-plot"
data-testid={`histogram-${field}-plot`}
ref={svgRef => this.drawHistogram(svgRef)}
/>
<div
style={{
display: "flex",
justifyContent: "space-between"
}}
>
<span style={{ color: unclippedRangeMinColor }}>
min {unclippedRangeMin.toPrecision(4)}
</span>
<span
data-testclass="brushable-histogram-field-name"
style={{ fontStyle: "italic" }}
>
{field}
</span>
<span style={{ color: unclippedRangeMaxColor }}>
max {unclippedRangeMax.toPrecision(4)}
</span>
</div>
{isDiffExp ? (
<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 HistogramBrush;