mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-25 19:48:11 +08:00
Gene sets UI, right sidebar refactor (#2097)
* prototyping
* render histos on open gene set
* prototyping
* render histos on open gene set
* factor out add genes to own component
* remove unused import
* mock reducer
* color by geneset stub
* menus and buttons
* geneset dialogue stub
* remove heatmap mock
* componetize histogram
* reenable add genes
* re-add isuserdefined
* test data
* remove have fetched
* add isExpanded state to gene, and pass to histogram
* expand button
* toggleable
* mini
* bump number of genes to 50
* don't clear diffexp on subset
* move create category to top
* render diffexp as geneset
* geneset show mean expression
* gene set reducer
* add geneset UI reducer
* wire e2e gene set loading prototype
* fix sniffing bug
* fix typo
* add gene modals
* client/src/actions/
* add autosave
* rename data-dir cli param
* add geneset, add gene, delete set
* prototype: remove csv upload placeholder
* handle delete gene from set
* prepopulate geneset with genes from modal
* add geneset: rename action
* icons, language consistency
* chevron after
* handle empty string case on genes for create geneset
* edit geneset
* fix language on create
* copy correction
* add popper2
upgrade react popper
upgrade react popper
adding popover2 package
* truncate uses tooltip2
* gene set button text typo
* remove logging
* moving server over
* remove test imports
* don't try to destructure map, use array.from
* fix add gene map datastructure error
* Revert "fix add gene map datastructure error"
This reverts commit b0eed45952.
* name --> genesetName, genes --> geneSymbols
* add gene to geneset, temporary format
* handle empty case, clear form input
* lint -- genesets wasn't passed via props
* userinfo
* move genes string to object conversion to action
* remove tmp gene description
* emptystring default for description
* remove empty string
* remove top level package json
* remove package lock as well
* remove flag for feature toggle
* remove comments in geneset
* comment cleanup
* remove comment
* revert diffexp genes to 10
* color by gene set
* disable color by gene set
* Gene menus are now inline, remove dead prototype code
* remove todo, magic number to variable
* remove jshint in rightsidebar
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
* remove unused geneset validation code
* tmp format pending geneset description
* move magic number into variable
* reorganize genesetsUI reducer pending tests
* rewire edit given new action name
* add basic validation and feedback for geneset name uniqueness
* mv annoDialog
* mv label, repair paths
* Update client/src/components/brushableHistogram/header.js
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
* add imports for icon in histo
* update jest snapshots given blueprint/tooltip2 usage of index -1
* ensure no empty paragraph
* intent from blueprint
* remove remainder of jshint references
* do not push undo when autosave fires
* fix autosave bugs
* remove todos
* clamp to util
* scient to util
* revert clearing diffexp
* rename value to be more specific stacked bar
* clean up logging and commetns
* remove gene entry tests pending rewrite
* tab index -1
* update jest snapshot, blueprint tooltip 2
* caret margin
* snapshot update
* ensure histogram is centered
* add geneset actions to config
* comment maybeScientific
* comment clamp
* comment ui reducer
* remove prototype code
* remove error log
* remove references to bl.ocks
* componetize parseBulkGeneString
* catch case where geneset rename same name
* genesetui reducer tests
* add geneset ui to index reducer config
Co-authored-by: bkmartinjr <bruce@chanzuckerberg.com>
Co-authored-by: Severiano Badajoz <sbadajoz@chanzuckerberg.com>
This commit is contained in:
co-authored by
Severiano Badajoz
bkmartinjr
parent
5335c39184
commit
e6e358ddc8
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
const ErrorLoading = ({ displayName, zebra }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: zebra ? globals.lightestGrey : "white",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
<span>{`Failure loading ${displayName}`}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorLoading;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
|
||||
const HistogramFooter = React.memo(
|
||||
({
|
||||
displayName,
|
||||
hideRanges,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
rangeColorMin,
|
||||
rangeColorMax,
|
||||
logFoldChange,
|
||||
pvalAdj,
|
||||
isObs,
|
||||
}) => {
|
||||
/*
|
||||
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" }}
|
||||
>
|
||||
{isObs ? displayName : null}
|
||||
</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,100 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Button, ButtonGroup, Tooltip, Icon } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
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>
|
||||
<Icon icon={IconNames.SCATTER_PLOT} style={{ marginRight: 7 }} />
|
||||
<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,185 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { interpolateCool } from "d3-scale-chromatic";
|
||||
import * as d3 from "d3";
|
||||
|
||||
import maybeScientific from "../../util/maybeScientific";
|
||||
import clamp from "../../util/clamp";
|
||||
|
||||
const Histogram = ({
|
||||
field,
|
||||
fieldForId,
|
||||
display,
|
||||
histogram,
|
||||
width,
|
||||
height,
|
||||
onBrush,
|
||||
onBrushEnd,
|
||||
margin,
|
||||
isColorBy,
|
||||
selectionRange,
|
||||
mini,
|
||||
}) => {
|
||||
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);
|
||||
const binPadding = mini ? 0 : -1;
|
||||
const defaultBarColor = mini ? "black" : "#bbb";
|
||||
|
||||
/* 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)) - binPadding)
|
||||
.attr("height", (d) => y(0) - y(d))
|
||||
.style(
|
||||
"fill",
|
||||
isColorBy
|
||||
? (d, i) => colorScale(histogramScale(binStart(i)))
|
||||
: defaultBarColor
|
||||
);
|
||||
}
|
||||
|
||||
if (!mini) {
|
||||
// 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;
|
||||
@@ -1,442 +1,17 @@
|
||||
/*
|
||||
https://bl.ocks.org/mbostock/4341954
|
||||
https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
|
||||
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
|
||||
https://bl.ocks.org/mbostock/3019563
|
||||
*/
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Button, ButtonGroup, Icon, Tooltip } from "@blueprintjs/core";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import { interpolateCool } from "d3-scale-chromatic";
|
||||
import Async from "react-async";
|
||||
import memoize from "memoize-one";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import { histogramContinuous } from "../../util/dataframe/histogram";
|
||||
import { makeContinuousDimensionName } from "../../util/nameCreators";
|
||||
import significantDigits from "../../util/significantDigits";
|
||||
|
||||
function clamp(val, rng) {
|
||||
return Math.max(Math.min(val, rng[1]), rng[0]);
|
||||
}
|
||||
|
||||
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 !== undefined && pvalAdj !== undefined ? (
|
||||
<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>
|
||||
<Icon icon={IconNames.SCATTER_PLOT} style={{ marginRight: 7 }} />
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
import HistogramHeader from "./header";
|
||||
import Histogram from "./histogram";
|
||||
import HistogramFooter from "./footer";
|
||||
import StillLoading from "./loading";
|
||||
import ErrorLoading from "./error";
|
||||
|
||||
@connect((state, ownProps) => {
|
||||
const { isObs, isUserDefined, isDiffExp, field } = ownProps;
|
||||
@@ -472,14 +47,25 @@ class HistogramBrush extends React.PureComponent {
|
||||
const marginRight = 54; // space for Y axis & labels
|
||||
const marginBottom = 25; // space for X axis & labels
|
||||
const marginTop = 3;
|
||||
this.margin = {
|
||||
marginLeft,
|
||||
marginRight,
|
||||
marginBottom,
|
||||
marginTop,
|
||||
|
||||
this.state = {
|
||||
margin: {
|
||||
marginLeft,
|
||||
marginRight,
|
||||
marginBottom,
|
||||
marginTop,
|
||||
},
|
||||
width: 340 - marginLeft - marginRight,
|
||||
height: 135 - marginTop - marginBottom,
|
||||
marginMini: {
|
||||
marginLeft: 0, // Space for 0 tick label on X axis
|
||||
marginRight: 0, // space for Y axis & labels
|
||||
marginBottom: 0, // space for X axis & labels
|
||||
marginTop: 0,
|
||||
},
|
||||
widthMini: 120,
|
||||
heightMini: 15,
|
||||
};
|
||||
this.width = 340 - marginLeft - marginRight;
|
||||
this.height = 135 - marginTop - marginBottom;
|
||||
}
|
||||
|
||||
onBrush = (selection, x, eventType) => {
|
||||
@@ -609,6 +195,14 @@ class HistogramBrush extends React.PureComponent {
|
||||
|
||||
fetchAsyncProps = async () => {
|
||||
const { annoMatrix } = this.props;
|
||||
const {
|
||||
margin,
|
||||
width,
|
||||
height,
|
||||
marginMini,
|
||||
widthMini,
|
||||
heightMini,
|
||||
} = this.state;
|
||||
const { isClipped } = annoMatrix;
|
||||
|
||||
const query = this.createQuery();
|
||||
@@ -636,11 +230,12 @@ class HistogramBrush extends React.PureComponent {
|
||||
: globals.blue,
|
||||
];
|
||||
|
||||
const histogram = this.calcHistogramCache(
|
||||
const histogram = this.calcHistogramCache(column, margin, width, height);
|
||||
const miniHistogram = this.calcHistogramCache(
|
||||
column,
|
||||
this.margin,
|
||||
this.width,
|
||||
this.height
|
||||
marginMini,
|
||||
widthMini,
|
||||
heightMini
|
||||
);
|
||||
|
||||
const isSingleValue = summary.min === summary.max;
|
||||
@@ -654,6 +249,7 @@ class HistogramBrush extends React.PureComponent {
|
||||
|
||||
return {
|
||||
histogram,
|
||||
miniHistogram,
|
||||
range,
|
||||
unclippedRange,
|
||||
unclippedRangeColor,
|
||||
@@ -667,13 +263,16 @@ class HistogramBrush extends React.PureComponent {
|
||||
/*
|
||||
recalculate expensive stuff, notably bins, summaries, etc.
|
||||
*/
|
||||
const histogramCache = {};
|
||||
const summary = col.summarize();
|
||||
const histogramCache = {}; /* maybe change this so that it computes ... */
|
||||
const summary = col.summarize(); /* this is memoized, so it's free the second time you call it */
|
||||
const { min: domainMin, max: domainMax } = summary;
|
||||
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
|
||||
.scaleLinear()
|
||||
@@ -683,7 +282,7 @@ class HistogramBrush extends React.PureComponent {
|
||||
histogramCache.bins = histogramContinuous(col, numBins, [
|
||||
domainMin,
|
||||
domainMax,
|
||||
]);
|
||||
]); /* memoized */
|
||||
histogramCache.binWidth = (domainMax - domainMin) / numBins;
|
||||
|
||||
histogramCache.binStart = (i) => domainMin + i * histogramCache.binWidth;
|
||||
@@ -733,7 +332,16 @@ class HistogramBrush extends React.PureComponent {
|
||||
zebra,
|
||||
continuousSelectionRange,
|
||||
isObs,
|
||||
mini,
|
||||
} = this.props;
|
||||
const {
|
||||
margin,
|
||||
width,
|
||||
height,
|
||||
marginMini,
|
||||
widthMini,
|
||||
heightMini,
|
||||
} = this.state;
|
||||
const fieldForId = field.replace(/\s/g, "_");
|
||||
const showScatterPlot = isDiffExp || isUserDefined;
|
||||
|
||||
@@ -761,48 +369,56 @@ class HistogramBrush extends React.PureComponent {
|
||||
: "histogram-continuous-metadata"
|
||||
}
|
||||
style={{
|
||||
padding: globals.leftSidebarSectionPadding,
|
||||
padding: mini ? 0 : globals.leftSidebarSectionPadding,
|
||||
backgroundColor: zebra ? globals.lightestGrey : "white",
|
||||
}}
|
||||
>
|
||||
<HistogramHeader
|
||||
fieldId={field}
|
||||
isColorBy={isColorAccessor}
|
||||
isObs={isObs}
|
||||
onColorByClick={this.handleColorAction(dispatch)}
|
||||
onRemoveClick={isUserDefined ? this.removeHistogram : null}
|
||||
isScatterPlotX={isScatterplotXXaccessor}
|
||||
isScatterPlotY={isScatterplotYYaccessor}
|
||||
onScatterPlotXClick={
|
||||
showScatterPlot ? this.handleSetGeneAsScatterplotX : null
|
||||
}
|
||||
onScatterPlotYClick={
|
||||
showScatterPlot ? this.handleSetGeneAsScatterplotY : null
|
||||
}
|
||||
/>
|
||||
{!mini && isObs ? (
|
||||
<HistogramHeader
|
||||
fieldId={field}
|
||||
isColorBy={isColorAccessor}
|
||||
isObs={isObs}
|
||||
onColorByClick={this.handleColorAction(dispatch)}
|
||||
onRemoveClick={isUserDefined ? this.removeHistogram : null}
|
||||
isScatterPlotX={isScatterplotXXaccessor}
|
||||
isScatterPlotY={isScatterplotYYaccessor}
|
||||
onScatterPlotXClick={
|
||||
showScatterPlot ? this.handleSetGeneAsScatterplotX : null
|
||||
}
|
||||
onScatterPlotYClick={
|
||||
showScatterPlot ? this.handleSetGeneAsScatterplotY : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Histogram
|
||||
field={field}
|
||||
fieldForId={fieldForId}
|
||||
display={asyncProps.isSingleValue ? "none" : "block"}
|
||||
histogram={asyncProps.histogram}
|
||||
width={this.width}
|
||||
height={this.height}
|
||||
histogram={
|
||||
mini ? asyncProps.miniHistogram : asyncProps.histogram
|
||||
}
|
||||
width={mini ? widthMini : width}
|
||||
height={mini ? heightMini : height}
|
||||
onBrush={this.onBrush}
|
||||
onBrushEnd={this.onBrushEnd}
|
||||
margin={this.margin}
|
||||
margin={mini ? marginMini : margin}
|
||||
isColorBy={isColorAccessor}
|
||||
selectionRange={continuousSelectionRange}
|
||||
mini={mini}
|
||||
/>
|
||||
<HistogramFooter
|
||||
displayName={field}
|
||||
hideRanges={asyncProps.isSingleValue}
|
||||
rangeMin={asyncProps.unclippedRange[0]}
|
||||
rangeMax={asyncProps.unclippedRange[1]}
|
||||
rangeColorMin={asyncProps.unclippedRangeColor[0]}
|
||||
rangeColorMax={asyncProps.unclippedRangeColor[1]}
|
||||
logFoldChange={logFoldChange}
|
||||
pvalAdj={pvalAdj}
|
||||
/>
|
||||
{!mini ? (
|
||||
<HistogramFooter
|
||||
isObs={isObs}
|
||||
displayName={field}
|
||||
hideRanges={asyncProps.isSingleValue}
|
||||
rangeMin={asyncProps.unclippedRange[0]}
|
||||
rangeMax={asyncProps.unclippedRange[1]}
|
||||
rangeColorMin={asyncProps.unclippedRangeColor[0]}
|
||||
rangeColorMax={asyncProps.unclippedRangeColor[1]}
|
||||
logFoldChange={logFoldChange}
|
||||
pvalAdj={pvalAdj}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user