From 56c5124c39d19e3e89458cc4d33da588e70e3f58 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Mon, 21 Sep 2020 14:51:39 -0700 Subject: [PATCH] componetize histogram --- .../components/brushableHistogram/clamp.js | 3 + .../components/brushableHistogram/error.js | 18 + .../components/brushableHistogram/footer.js | 89 ++++ .../components/brushableHistogram/header.js | 102 +++++ .../brushableHistogram/histogram.js | 178 +++++++ .../components/brushableHistogram/index.js | 433 +----------------- .../components/brushableHistogram/loading.js | 42 ++ .../brushableHistogram/maybeScientific.js | 17 + client/src/components/geneExpression/gene.js | 6 +- .../src/components/geneExpression/geneSet.js | 40 +- 10 files changed, 485 insertions(+), 443 deletions(-) create mode 100644 client/src/components/brushableHistogram/clamp.js create mode 100644 client/src/components/brushableHistogram/error.js create mode 100644 client/src/components/brushableHistogram/footer.js create mode 100644 client/src/components/brushableHistogram/header.js create mode 100644 client/src/components/brushableHistogram/histogram.js create mode 100644 client/src/components/brushableHistogram/loading.js create mode 100644 client/src/components/brushableHistogram/maybeScientific.js diff --git a/client/src/components/brushableHistogram/clamp.js b/client/src/components/brushableHistogram/clamp.js new file mode 100644 index 00000000..9ebeb12a --- /dev/null +++ b/client/src/components/brushableHistogram/clamp.js @@ -0,0 +1,3 @@ +export default function clamp(val, rng) { + return Math.max(Math.min(val, rng[1]), rng[0]); +} diff --git a/client/src/components/brushableHistogram/error.js b/client/src/components/brushableHistogram/error.js new file mode 100644 index 00000000..4c10126e --- /dev/null +++ b/client/src/components/brushableHistogram/error.js @@ -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 ( +
+ {`Failure loading ${displayName}`} +
+ ); +}; + +export default ErrorLoading; diff --git a/client/src/components/brushableHistogram/footer.js b/client/src/components/brushableHistogram/footer.js new file mode 100644 index 00000000..d2cdc955 --- /dev/null +++ b/client/src/components/brushableHistogram/footer.js @@ -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 ( +
+
+ + min {rangeMin.toPrecision(4)} + + + {displayName} + +
+ : {rangeMin} +
+ + max {rangeMax.toPrecision(4)} + +
+ + {logFoldChange && pvalAdj ? ( +
+ + log fold change: + {` ${logFoldChange.toPrecision(4)}`} + + + p-value (adj): + {pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`} + +
+ ) : null} +
+ ); + } +); + +export default HistogramFooter; diff --git a/client/src/components/brushableHistogram/header.js b/client/src/components/brushableHistogram/header.js new file mode 100644 index 00000000..ba11071e --- /dev/null +++ b/client/src/components/brushableHistogram/header.js @@ -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 ( +
+ {onScatterPlotXClick && onScatterPlotYClick ? ( + + + + + + + + ) : null} + {onRemoveClick ? ( + + ) : null} + +
+ ); + } +); + +export default HistogramHeader; diff --git a/client/src/components/brushableHistogram/histogram.js b/client/src/components/brushableHistogram/histogram.js new file mode 100644 index 00000000..1b267215 --- /dev/null +++ b/client/src/components/brushableHistogram/histogram.js @@ -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 ( + + ); +}; + +export default Histogram; diff --git a/client/src/components/brushableHistogram/index.js b/client/src/components/brushableHistogram/index.js index 71175b2d..b287bc35 100644 --- a/client/src/components/brushableHistogram/index.js +++ b/client/src/components/brushableHistogram/index.js @@ -4,441 +4,20 @@ 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, 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 * 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 ( -
-
-
-
- {displayName} -
-
-
-
-
- ); -}; - -const ErrorLoading = ({ displayName, error, zebra }) => { - console.log(error); // log to console as this is unexpected - return ( -
- {`Failure loading ${displayName}`} -
- ); -}; - -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 ( -
-
- - min {rangeMin.toPrecision(4)} - - - {displayName} - -
- : {rangeMin} -
- - max {rangeMax.toPrecision(4)} - -
- - {logFoldChange && pvalAdj ? ( -
- - log fold change: - {` ${logFoldChange.toPrecision(4)}`} - - - p-value (adj): - {pvalAdj < 0.0001 ? " < 0.0001" : ` ${pvalAdj.toFixed(4)}`} - -
- ) : null} -
- ); - } -); - -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 ( -
- {onScatterPlotXClick && onScatterPlotYClick ? ( - - - - - - - - ) : null} - {onRemoveClick ? ( - - ) : null} - -
- ); - } -); - -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 ( - - ); -}; +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; diff --git a/client/src/components/brushableHistogram/loading.js b/client/src/components/brushableHistogram/loading.js new file mode 100644 index 00000000..7a195c78 --- /dev/null +++ b/client/src/components/brushableHistogram/loading.js @@ -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 ( +
+
+
+
+ {displayName} +
+
+
+
+
+ ); +}; + +export default StillLoading; diff --git a/client/src/components/brushableHistogram/maybeScientific.js b/client/src/components/brushableHistogram/maybeScientific.js new file mode 100644 index 00000000..0a3da3d1 --- /dev/null +++ b/client/src/components/brushableHistogram/maybeScientific.js @@ -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; +} diff --git a/client/src/components/geneExpression/gene.js b/client/src/components/geneExpression/gene.js index 81823ca1..c5f5e4cc 100644 --- a/client/src/components/geneExpression/gene.js +++ b/client/src/components/geneExpression/gene.js @@ -7,8 +7,9 @@ import { connect } from "react-redux"; import { AnchorButton, Icon } from "@blueprintjs/core"; 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 GeneMenus from "./menus/geneMenus"; @@ -85,7 +86,7 @@ class Gene extends React.Component { {gene} - + {/* */}
@@ -112,6 +113,7 @@ class Gene extends React.Component { icon={} />
+ ); } diff --git a/client/src/components/geneExpression/geneSet.js b/client/src/components/geneExpression/geneSet.js index 42a6a3fd..be94d669 100644 --- a/client/src/components/geneExpression/geneSet.js +++ b/client/src/components/geneExpression/geneSet.js @@ -4,15 +4,15 @@ import React from "react"; import _ from "lodash"; 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 actions from "../../actions"; import Gene from "./gene"; import { memoize } from "../../util/dataframe/util"; import Truncate from "../util/truncate"; -import TestMiniHisto from "./test_miniHisto"; +// import TestMiniHisto from "./test_miniHisto"; import * as globals from "../../globals"; -import GenesetMenus from "./menus/genesetMenus"; +// import GenesetMenus from "./menus/genesetMenus"; @connect((state, ownProps) => { return { @@ -140,7 +140,7 @@ class GeneSet extends React.Component { style={{ maxWidth: 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`} > @@ -149,16 +149,28 @@ class GeneSet extends React.Component {
- - - } - /> + {/* */} + {/* */} + + } + /> +