mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-25 18: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
@@ -18,6 +18,8 @@ class AnnoDialog extends React.PureComponent {
|
||||
validationError,
|
||||
annoSelect,
|
||||
annoInput,
|
||||
secondaryInstructions,
|
||||
secondaryInput,
|
||||
handleCancel,
|
||||
handleSubmit,
|
||||
primaryButtonText,
|
||||
@@ -46,6 +48,12 @@ class AnnoDialog extends React.PureComponent {
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
{secondaryInstructions && (
|
||||
<p style={{ marginTop: secondaryInstructions ? 20 : 0 }}>
|
||||
{secondaryInstructions}
|
||||
</p>
|
||||
)}
|
||||
{secondaryInput || null}
|
||||
</div>
|
||||
{annoSelect || null}
|
||||
</div>
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import Helmet from "react-helmet";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
auth: state.config?.authentication,
|
||||
userInfo: state.userInfo,
|
||||
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
||||
writableGenesetsEnabled: !(
|
||||
state.config?.parameters?.["annotations_genesets_readonly"] ?? true
|
||||
),
|
||||
}))
|
||||
class FilenameDialog extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -95,13 +98,14 @@ class FilenameDialog extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
writableCategoriesEnabled,
|
||||
writableGenesetsEnabled,
|
||||
annotations,
|
||||
idhash,
|
||||
userInfo,
|
||||
} = this.props;
|
||||
const { filenameText } = this.state;
|
||||
|
||||
return writableCategoriesEnabled &&
|
||||
return (writableCategoriesEnabled || writableGenesetsEnabled) &&
|
||||
annotations.promptForFilename &&
|
||||
!annotations.dataCollectionNameIsReadOnly &&
|
||||
!annotations.dataCollectionName &&
|
||||
|
||||
@@ -67,7 +67,7 @@ class Autosave extends React.Component {
|
||||
needToSaveGenesets = () => {
|
||||
/* return true if we need to save gene ses, false if we do not */
|
||||
const { genesets, lastSavedGenesets } = this.props;
|
||||
return genesets.initialized && genesets !== lastSavedGenesets;
|
||||
return genesets.initialized && genesets.genesets !== lastSavedGenesets;
|
||||
};
|
||||
|
||||
needToSave() {
|
||||
|
||||
@@ -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;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import AnnoDialog from "../annoDialog";
|
||||
import LabelInput from "../labelInput";
|
||||
import AnnoDialog from "../../annoDialog";
|
||||
import LabelInput from "../../labelInput";
|
||||
import { labelPrompt, isLabelErroneous } from "../labelUtil";
|
||||
import actions from "../../../actions";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import AnnoDialog from "../annoDialog";
|
||||
import LabelInput from "../labelInput";
|
||||
import AnnoDialog from "../../annoDialog";
|
||||
import LabelInput from "../../labelInput";
|
||||
import { labelPrompt } from "../labelUtil";
|
||||
|
||||
import { AnnotationsHelpers } from "../../../util/stateManager";
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
Tooltip,
|
||||
Icon,
|
||||
PopoverInteractionKind,
|
||||
Intent,
|
||||
} from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
import * as globals from "../../../globals";
|
||||
import actions from "../../../actions";
|
||||
@@ -89,8 +91,8 @@ class AnnoMenuCategory extends React.PureComponent {
|
||||
text={editText}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="delete"
|
||||
intent="danger"
|
||||
icon={IconNames.TRASH}
|
||||
intent={Intent.DANGER}
|
||||
data-testclass="handleDeleteCategory"
|
||||
data-testid={`${metadataField}:delete-category`}
|
||||
onClick={this.handleDeleteCategory}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { AnchorButton, Tooltip, Position } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import Category from "./category";
|
||||
import { AnnotationsHelpers, ControlsHelpers } from "../../util/stateManager";
|
||||
import AnnoDialog from "./annoDialog";
|
||||
import AnnoDialog from "../annoDialog";
|
||||
import AnnoSelect from "./annoSelect";
|
||||
import LabelInput from "./labelInput";
|
||||
import LabelInput from "../labelInput";
|
||||
import { labelPrompt } from "./labelUtil";
|
||||
import actions from "../../actions";
|
||||
|
||||
@@ -182,6 +181,35 @@ class Categories extends React.Component {
|
||||
}
|
||||
/>
|
||||
|
||||
{writableCategoriesEnabled ? (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<Tooltip
|
||||
content={
|
||||
userInfo.is_authenticated
|
||||
? "Create a new category"
|
||||
: "You must be logged in to create new categorical fields"
|
||||
}
|
||||
position={Position.RIGHT}
|
||||
boundary="viewport"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
modifiers={{
|
||||
preventOverflow: { enabled: false },
|
||||
hide: { enabled: false },
|
||||
}}
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
data-testid="open-annotation-dialog"
|
||||
onClick={this.handleEnableAnnoMode}
|
||||
intent="primary"
|
||||
disabled={!userInfo.is_authenticated}
|
||||
>
|
||||
Create new <strong>category</strong>
|
||||
</AnchorButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* READ ONLY CATEGORICAL FIELDS */}
|
||||
{/* this is duplicative but flat, could be abstracted */}
|
||||
{allCategoryNames.map((catName) =>
|
||||
@@ -209,33 +237,6 @@ class Categories extends React.Component {
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{writableCategoriesEnabled ? (
|
||||
<Tooltip
|
||||
content={
|
||||
userInfo.is_authenticated
|
||||
? "Create a new category"
|
||||
: "You must be logged in to create new categorical fields"
|
||||
}
|
||||
position={Position.RIGHT}
|
||||
boundary="viewport"
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
modifiers={{
|
||||
preventOverflow: { enabled: false },
|
||||
hide: { enabled: false },
|
||||
}}
|
||||
>
|
||||
<AnchorButton
|
||||
type="button"
|
||||
data-testid="open-annotation-dialog"
|
||||
onClick={this.handleEnableAnnoMode}
|
||||
intent="primary"
|
||||
disabled={!userInfo.is_authenticated}
|
||||
>
|
||||
Create new category
|
||||
</AnchorButton>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@blueprintjs/core";
|
||||
import * as globals from "../../../globals";
|
||||
import styles from "../categorical.css";
|
||||
import AnnoDialog from "../annoDialog";
|
||||
import LabelInput from "../labelInput";
|
||||
import AnnoDialog from "../../annoDialog";
|
||||
import LabelInput from "../../labelInput";
|
||||
import Truncate from "../../util/truncate";
|
||||
|
||||
import { AnnotationsHelpers } from "../../../util/stateManager";
|
||||
@@ -25,8 +25,8 @@ import MiniHistogram from "../../miniHistogram";
|
||||
import MiniStackedBar from "../../miniStackedBar";
|
||||
import { CategoryCrossfilterContext } from "../categoryContext";
|
||||
|
||||
const VALUE_HEIGHT = 11;
|
||||
const CHART_WIDTH = 100;
|
||||
const STACKED_BAR_HEIGHT = 11;
|
||||
const STACKED_BAR_WIDTH = 100;
|
||||
|
||||
/* this is defined outside of the class so we can use it in connect() */
|
||||
function _currentLabelAsString(ownProps) {
|
||||
@@ -410,7 +410,7 @@ class CategoryValue extends React.Component {
|
||||
label,
|
||||
colorTable,
|
||||
schema,
|
||||
CHART_WIDTH
|
||||
STACKED_BAR_WIDTH
|
||||
) ?? {};
|
||||
|
||||
if (!domainValues || !scale || !domain || !occupancy) {
|
||||
@@ -426,8 +426,8 @@ class CategoryValue extends React.Component {
|
||||
domain,
|
||||
occupancy,
|
||||
}}
|
||||
height={VALUE_HEIGHT}
|
||||
width={CHART_WIDTH}
|
||||
height={STACKED_BAR_HEIGHT}
|
||||
width={STACKED_BAR_WIDTH}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -460,8 +460,8 @@ class CategoryValue extends React.Component {
|
||||
colorAccessor,
|
||||
colorData,
|
||||
label,
|
||||
CHART_WIDTH,
|
||||
VALUE_HEIGHT
|
||||
STACKED_BAR_WIDTH,
|
||||
STACKED_BAR_HEIGHT
|
||||
) ?? {}; // if createHistogramBins returns empty object assign null to deconstructed
|
||||
|
||||
if (!xScale || !yScale || !bins) return null;
|
||||
@@ -476,8 +476,8 @@ class CategoryValue extends React.Component {
|
||||
}}
|
||||
obsOrVarContinuousFieldDisplayName={colorAccessor}
|
||||
domainLabel={label}
|
||||
height={VALUE_HEIGHT}
|
||||
width={CHART_WIDTH}
|
||||
height={STACKED_BAR_HEIGHT}
|
||||
width={STACKED_BAR_WIDTH}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -534,7 +534,7 @@ class CategoryValue extends React.Component {
|
||||
colorAccessor && !isColorBy
|
||||
? globals.leftSidebarWidth -
|
||||
otherElementsWidth -
|
||||
CHART_WIDTH -
|
||||
STACKED_BAR_WIDTH -
|
||||
CHART_MARGIN
|
||||
: globals.leftSidebarWidth - otherElementsWidth;
|
||||
|
||||
@@ -677,8 +677,8 @@ class CategoryValue extends React.Component {
|
||||
display={isColorBy && categoryValueIndices ? "auto" : "none"}
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
width: VALUE_HEIGHT,
|
||||
height: VALUE_HEIGHT,
|
||||
width: 15,
|
||||
height: 15,
|
||||
backgroundColor:
|
||||
isColorBy && categoryValueIndices
|
||||
? colorScale(categoryValueIndices.get(label))
|
||||
@@ -739,7 +739,7 @@ class CategoryValue extends React.Component {
|
||||
) : null}
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="delete"
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
data-testclass="handleDeleteValue"
|
||||
data-testid={`${metadataField}:${displayString}:delete-label`}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
Setup SVG & Canvas elements
|
||||
******************************************
|
||||
******************************************/
|
||||
// jshint esversion: 6
|
||||
import * as d3 from "d3";
|
||||
|
||||
const setupParallelCoordinates = (width, height, margin) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import * as d3 from "d3";
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
} from "../../util/stateManager/colorHelpers";
|
||||
|
||||
// create continuous color legend
|
||||
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
||||
const continuous = (selectorId, colorScale, colorAccessor) => {
|
||||
const legendHeight = 200;
|
||||
const legendWidth = 80;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
|
||||
function Container(props) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { AnchorButton, Icon } from "@blueprintjs/core";
|
||||
import Truncate from "../util/truncate";
|
||||
import HistogramBrush from "../brushableHistogram";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
|
||||
@connect((state, ownProps) => {
|
||||
const { gene } = ownProps;
|
||||
|
||||
return {
|
||||
isColorAccessor: state.colors.colorAccessor === gene,
|
||||
isScatterplotXXaccessor: state.controls.scatterplotXXaccessor === gene,
|
||||
isScatterplotYYaccessor: state.controls.scatterplotYYaccessor === gene,
|
||||
};
|
||||
})
|
||||
class Gene extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
geneIsExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
onColorChangeClick = () => {
|
||||
const { dispatch, gene } = this.props;
|
||||
dispatch(actions.requestSingleGeneExpressionCountsForColoringPOST(gene));
|
||||
};
|
||||
|
||||
handleGeneExpandClick = () => {
|
||||
const { geneIsExpanded } = this.state;
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
handleDeleteGeneFromSet = () => {
|
||||
const { dispatch, gene, geneset } = this.props;
|
||||
|
||||
dispatch({
|
||||
type: "geneset: delete genes",
|
||||
genesetName: geneset,
|
||||
geneSymbols: [gene],
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
gene,
|
||||
isColorAccessor,
|
||||
isScatterplotXXaccessor,
|
||||
isScatterplotYYaccessor,
|
||||
isDiffexp,
|
||||
} = this.props;
|
||||
const { geneIsExpanded } = this.state;
|
||||
const genesetNameLengthVisible = 310; /* this magic number determines how much of a long geneset name we see */
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
marginRight: 0,
|
||||
marginTop: 2,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="menuitem"
|
||||
tabIndex="0"
|
||||
data-testclass="gene-expand"
|
||||
data-testid={`${gene}:gene-expand`}
|
||||
onKeyPress={() => {}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
icon="drag-handle-horizontal"
|
||||
iconSize={12}
|
||||
style={{
|
||||
marginRight: 7,
|
||||
cursor: "grab",
|
||||
position: "relative",
|
||||
top: -1,
|
||||
}}
|
||||
/>
|
||||
<Truncate>
|
||||
<span
|
||||
style={{
|
||||
width: globals.leftSidebarWidth - genesetNameLengthVisible,
|
||||
}}
|
||||
data-testid={`${gene}:gene-label`}
|
||||
>
|
||||
{gene}
|
||||
</span>
|
||||
</Truncate>
|
||||
</div>
|
||||
{!geneIsExpanded ? (
|
||||
<HistogramBrush isUserDefined field={gene} mini />
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, marginLeft: 2 }}>
|
||||
{!isDiffexp ? (
|
||||
<AnchorButton
|
||||
minimal
|
||||
small
|
||||
data-testid={`delete-from-geneset-${gene}`}
|
||||
onClick={this.handleDeleteGeneFromSet}
|
||||
intent="none"
|
||||
style={{ fontWeight: 700, marginRight: 2 }}
|
||||
icon={<Icon icon="trash" iconSize={10} />}
|
||||
/>
|
||||
) : null}
|
||||
<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
|
||||
minimal
|
||||
small
|
||||
data-testclass="maximize"
|
||||
data-testid={`maximize-${gene}`}
|
||||
onClick={this.handleGeneExpandClick}
|
||||
active={geneIsExpanded}
|
||||
intent="none"
|
||||
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} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{geneIsExpanded ? <HistogramBrush isUserDefined field={gene} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Gene;
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import { Tooltip, Position, Switch } 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 * as globals from "../../globals";
|
||||
import GenesetMenus from "./menus/genesetMenus";
|
||||
|
||||
@connect((state, ownProps) => {
|
||||
return {
|
||||
world: state.world,
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||
isColorAccessor: state.colors.colorAccessor === ownProps.setName,
|
||||
};
|
||||
})
|
||||
class GeneSet extends React.Component {
|
||||
_memoGenesToUpper = memoize(this._genesToUpper, (arr) => arr);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isOpen: false,
|
||||
toggleSummaryHisto: false,
|
||||
};
|
||||
}
|
||||
|
||||
_genesToUpper = (listGenes) => {
|
||||
// Has to be a Map to preserve index
|
||||
const upperGenes = new Map();
|
||||
for (let i = 0, { length } = listGenes; i < length; i += 1) {
|
||||
upperGenes.set(listGenes[i].toUpperCase(), i);
|
||||
}
|
||||
|
||||
return upperGenes;
|
||||
};
|
||||
|
||||
fetchGenes = () => {
|
||||
const { world, dispatch, setGenes } = this.props;
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
|
||||
const worldGenes = world.varAnnotations.col(varIndexName).asArray();
|
||||
|
||||
const upperGenes = this._genesToUpper(setGenes);
|
||||
const upperWorldGenes = this._memoGenesToUpper(worldGenes);
|
||||
|
||||
dispatch({ type: "bulk user defined gene start" });
|
||||
|
||||
Promise.all(
|
||||
[...upperGenes.keys()].map((upperGene) => {
|
||||
const indexOfGene = upperWorldGenes.get(upperGene);
|
||||
|
||||
return dispatch(
|
||||
actions.requestUserDefinedGene(worldGenes[indexOfGene])
|
||||
);
|
||||
})
|
||||
).then(
|
||||
() => dispatch({ type: "bulk user defined gene complete" }),
|
||||
() => dispatch({ type: "bulk user defined gene error" })
|
||||
);
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
onGenesetMenuClick = () => {
|
||||
const { isOpen } = this.state;
|
||||
this.setState({ isOpen: !isOpen });
|
||||
};
|
||||
|
||||
onColorChangeClick = () => {
|
||||
// const { dispatch, setName } = this.props;
|
||||
// dispatch({
|
||||
// type: "color by gene set",
|
||||
// colorAccessor: setName,
|
||||
// });
|
||||
};
|
||||
|
||||
toggleSummaryHisto = () => {
|
||||
const { toggleSummaryHisto } = this.state;
|
||||
this.setState({ toggleSummaryHisto: !toggleSummaryHisto });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { setName, setGenes, isDiffexp } = this.props;
|
||||
const { isOpen, toggleSummaryHisto } = this.state;
|
||||
const genesetNameLengthVisible = 120; /* this magic number determines how much of a long geneset name we see */
|
||||
return (
|
||||
<div style={{ marginBottom: 3 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
role="menuitem"
|
||||
tabIndex="0"
|
||||
data-testclass="geneset-expand"
|
||||
data-testid={`${setName}:geneset-expand`}
|
||||
onKeyPress={
|
||||
/* TODO(colinmegill): #2101: click handler on span */ () => {}
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
onClick={this.onGenesetMenuClick}
|
||||
>
|
||||
<Truncate>
|
||||
<span
|
||||
style={{
|
||||
maxWidth: globals.leftSidebarWidth - genesetNameLengthVisible,
|
||||
}}
|
||||
data-testid={`${setName}:geneset-label`}
|
||||
>
|
||||
{setName}
|
||||
</span>
|
||||
</Truncate>
|
||||
{isOpen ? (
|
||||
<FaChevronDown
|
||||
data-testclass="geneset-expand-is-expanded"
|
||||
style={{ fontSize: 10, marginLeft: 5 }}
|
||||
/>
|
||||
) : (
|
||||
<FaChevronRight
|
||||
data-testclass="geneset-expand-is-not-expanded"
|
||||
style={{ fontSize: 10, marginLeft: 5 }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<GenesetMenus genesetsEditable geneset={setName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginLeft: 15, marginTop: 5, marginRight: 0 }}>
|
||||
{isOpen ? (
|
||||
<Tooltip
|
||||
content="Aggregate all genes in this geneset, and allow coloring by and selecting on the entire set."
|
||||
position={Position.BOTTOM_RIGHT}
|
||||
usePortal
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
modifiers={{
|
||||
preventOverflow: { enabled: false },
|
||||
hide: { enabled: false },
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
style={{ fontStyle: "italic" }}
|
||||
checked={toggleSummaryHisto}
|
||||
label="Show mean expression across entire set"
|
||||
alignIndicator="left"
|
||||
innerLabel="off"
|
||||
innerLabelChecked="on"
|
||||
onChange={this.toggleSummaryHisto}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isOpen && !toggleSummaryHisto
|
||||
? _.map(setGenes, (gene) => {
|
||||
return (
|
||||
<Gene
|
||||
key={gene}
|
||||
gene={gene}
|
||||
geneset={setName}
|
||||
isDiffexp={isDiffexp}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GeneSet;
|
||||
@@ -1,59 +1,78 @@
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import HistogramBrush from "../brushableHistogram";
|
||||
import * as globals from "../../globals";
|
||||
import AddGenes from "./addGenes";
|
||||
import { Button } from "@blueprintjs/core";
|
||||
import GeneSet from "./geneSet";
|
||||
|
||||
import CreateGenesetDialogue from "./menus/createGenesetDialogue";
|
||||
import EditGenesetNameDialogue from "./menus/editGenesetNameDialogue";
|
||||
|
||||
@connect((state) => {
|
||||
return {
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
differential: state.differential,
|
||||
genesets: state.genesets.genesets,
|
||||
};
|
||||
})
|
||||
class GeneExpression extends React.Component {
|
||||
renderGeneSets = () => {
|
||||
const sets = [];
|
||||
const { genesets } = this.props;
|
||||
|
||||
for (const [name, genes] of genesets) {
|
||||
sets.push(
|
||||
<GeneSet
|
||||
key={name}
|
||||
setGenes={Array.from(genes.genes.keys())}
|
||||
setName={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return sets;
|
||||
};
|
||||
|
||||
renderDiffexpGeneSets = () => {
|
||||
const { differential } = this.props;
|
||||
|
||||
const setGenes = [];
|
||||
|
||||
if (differential.diffExp) {
|
||||
differential.diffExp.forEach((diffexpGene) => {
|
||||
setGenes.push(diffexpGene[0]);
|
||||
});
|
||||
}
|
||||
|
||||
return differential.diffExp ? (
|
||||
<GeneSet
|
||||
key="Temp DiffExp Set"
|
||||
setGenes={setGenes}
|
||||
isDiffexp
|
||||
setName="Temp DiffExp Set"
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
handleActivateCreateGenesetMode = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({ type: "geneset: activate add new geneset mode" });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { userDefinedGenes, differential } = this.props;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderBottom: `1px solid ${globals.lighterGrey}`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<AddGenes />
|
||||
{userDefinedGenes.length > 0
|
||||
? _.map(userDefinedGenes, (geneName, index) => {
|
||||
return (
|
||||
<HistogramBrush
|
||||
key={geneName}
|
||||
field={geneName}
|
||||
zebra={index % 2 === 0}
|
||||
isUserDefined
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
<div>
|
||||
{differential.diffExp
|
||||
? _.map(differential.diffExp, (value, index) => {
|
||||
return (
|
||||
<HistogramBrush
|
||||
key={value[0]}
|
||||
field={value[0]}
|
||||
zebra={index % 2 === 0}
|
||||
isDiffExp
|
||||
logFoldChange={value[1]}
|
||||
pval={value[2]}
|
||||
pvalAdj={value[3]}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
<div style={{ marginBottom: 10, position: "relative", top: -2 }}>
|
||||
<Button
|
||||
data-testid="open-create-geneset-dialog"
|
||||
onClick={this.handleActivateCreateGenesetMode}
|
||||
intent="primary"
|
||||
>
|
||||
Create new <strong>gene set</strong>
|
||||
</Button>
|
||||
</div>
|
||||
<CreateGenesetDialogue />
|
||||
<EditGenesetNameDialogue />
|
||||
</div>
|
||||
<div>{this.renderDiffexpGeneSets()}</div>
|
||||
<div>{this.renderGeneSets()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import AnnoDialog from "../../annoDialog";
|
||||
import LabelInput from "../../labelInput";
|
||||
import parseBulkGeneString from "../../../util/parseBulkGeneString";
|
||||
|
||||
@connect((state) => ({
|
||||
genesetsUI: state.genesetsUI,
|
||||
}))
|
||||
class AddGeneToGenesetDialogue extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
genesToAdd: "",
|
||||
};
|
||||
}
|
||||
|
||||
disableAddGeneMode = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "geneset: disable add new genes mode",
|
||||
});
|
||||
};
|
||||
|
||||
handleAddGeneToGeneSet = (e) => {
|
||||
const { geneset, dispatch } = this.props;
|
||||
const { genesToAdd } = this.state;
|
||||
|
||||
const genesTmpHardcodedFormat = [];
|
||||
|
||||
const genesArrayFromString = parseBulkGeneString(genesToAdd);
|
||||
|
||||
genesArrayFromString.forEach((_gene) => {
|
||||
genesTmpHardcodedFormat.push({
|
||||
geneSymbol: _gene,
|
||||
});
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "geneset: add genes",
|
||||
genesetName: geneset,
|
||||
genes: genesTmpHardcodedFormat,
|
||||
});
|
||||
dispatch({
|
||||
type: "geneset: disable add new genes mode",
|
||||
});
|
||||
if (e) e.preventDefault();
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
this.setState({ genesToAdd: e });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { geneset, genesetsUI } = this.props;
|
||||
const { genesToAdd } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnoDialog
|
||||
isActive={genesetsUI.isAddingGenesToGeneset === geneset}
|
||||
inputProps={{ "data-testid": `${geneset}:create-label-dialog` }}
|
||||
primaryButtonProps={{
|
||||
"data-testid": `${geneset}:submit-label`,
|
||||
}}
|
||||
title="Add genes to gene set"
|
||||
instruction={`Add gene to ${geneset}`}
|
||||
cancelTooltipContent="Close this dialog without adding genes to gene set."
|
||||
primaryButtonText="Add genes"
|
||||
text={genesToAdd}
|
||||
validationError={false}
|
||||
annoInput={
|
||||
<LabelInput
|
||||
onChange={this.handleChange}
|
||||
inputProps={{
|
||||
"data-testid": "add-genes",
|
||||
leftIcon: "manually-entered-data",
|
||||
intent: "none",
|
||||
autoFocus: true,
|
||||
}}
|
||||
newLabelMessage="New category"
|
||||
/>
|
||||
}
|
||||
handleSubmit={this.handleAddGeneToGeneSet}
|
||||
handleCancel={this.disableAddGeneMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddGeneToGenesetDialogue;
|
||||
+6
-8
@@ -1,7 +1,4 @@
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { connect } from "react-redux";
|
||||
import { Suggest } from "@blueprintjs/select";
|
||||
@@ -13,14 +10,15 @@ import {
|
||||
Intent,
|
||||
MenuItem,
|
||||
} from "@blueprintjs/core";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import * as globals from "../../../globals";
|
||||
import actions from "../../../actions";
|
||||
import {
|
||||
postUserErrorToast,
|
||||
keepAroundErrorToast,
|
||||
} from "../framework/toasters";
|
||||
} from "../../framework/toasters";
|
||||
|
||||
import { memoize } from "../../util/dataframe/util";
|
||||
import { memoize } from "../../../util/dataframe/util";
|
||||
import parseBulkGeneString from "../../../util/parseBulkGeneString";
|
||||
|
||||
const renderGene = (fuzzySortResult, { handleClick, modifiers }) => {
|
||||
if (!modifiers.matchesPredicate) {
|
||||
@@ -101,7 +99,7 @@ class AddGenes extends React.Component {
|
||||
Apod,,, Cd74,, ,,, Foo, Bar-2,,
|
||||
*/
|
||||
if (bulkAdd !== "") {
|
||||
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
||||
const genes = parseBulkGeneString(bulkAdd);
|
||||
if (genes.length === 0) {
|
||||
return keepAroundErrorToast("Must enter a gene name.");
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import _ from "lodash";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import AnnoDialog from "../../annoDialog";
|
||||
import LabelInput from "../../labelInput";
|
||||
|
||||
@connect((state) => ({
|
||||
annotations: state.annotations,
|
||||
schema: state.annoMatrix?.schema,
|
||||
ontology: state.ontology,
|
||||
obsCrossfilter: state.obsCrossfilter,
|
||||
genesets: state.genesets.genesets,
|
||||
genesetsUI: state.genesetsUI,
|
||||
}))
|
||||
class CreateGenesetDialogue extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
genesetName: "",
|
||||
genesToPopulateGeneset: "",
|
||||
};
|
||||
}
|
||||
|
||||
disableCreateGenesetMode = (e) => {
|
||||
const { dispatch } = this.props;
|
||||
this.setState({
|
||||
genesetName: "",
|
||||
genesToPopulateGeneset: "",
|
||||
});
|
||||
dispatch({
|
||||
type: "geneset: disable create geneset mode",
|
||||
});
|
||||
if (e) e.preventDefault();
|
||||
};
|
||||
|
||||
createGeneset = (e) => {
|
||||
const { dispatch } = this.props;
|
||||
const { genesetName, genesToPopulateGeneset } = this.state;
|
||||
|
||||
dispatch({
|
||||
type: "geneset: create",
|
||||
genesetName,
|
||||
genesetDescription: "",
|
||||
});
|
||||
if (genesToPopulateGeneset) {
|
||||
const genesTmpHardcodedFormat = [];
|
||||
|
||||
const genesArrayFromString = _.pull(
|
||||
_.uniq(genesToPopulateGeneset.split(/[ ,]+/)),
|
||||
""
|
||||
);
|
||||
|
||||
genesArrayFromString.forEach((_gene) => {
|
||||
genesTmpHardcodedFormat.push({
|
||||
geneSymbol: _gene,
|
||||
});
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "geneset: add genes",
|
||||
genesetName,
|
||||
genes: genesTmpHardcodedFormat,
|
||||
});
|
||||
}
|
||||
dispatch({
|
||||
type: "geneset: disable create geneset mode",
|
||||
});
|
||||
this.setState({
|
||||
genesetName: "",
|
||||
genesToPopulateGeneset: "",
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
genesetNameError = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
this.setState({ genesetName: e });
|
||||
};
|
||||
|
||||
handleGenesetInputChange = (e) => {
|
||||
this.setState({ genesToPopulateGeneset: e });
|
||||
};
|
||||
|
||||
instruction = (genesetName, genesets) => {
|
||||
return genesets.has(genesetName)
|
||||
? "Geneset name must be unique."
|
||||
: "New, unique geneset name";
|
||||
};
|
||||
|
||||
validate = (genesetName, genesets) => {
|
||||
return genesets.has(genesetName);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { genesetName } = this.state;
|
||||
const { metadataField, genesetsUI, genesets } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnoDialog
|
||||
isActive={genesetsUI.createGenesetModeActive}
|
||||
inputProps={{
|
||||
"data-testid": `${metadataField}:create-geneset-dialog`,
|
||||
}}
|
||||
primaryButtonProps={{
|
||||
"data-testid": `${metadataField}:submit-geneset`,
|
||||
}}
|
||||
title="Create gene set"
|
||||
instruction={this.instruction(genesetName, genesets)}
|
||||
cancelTooltipContent="Close this dialog without creating a new gene set."
|
||||
primaryButtonText="Create gene set"
|
||||
text={genesetName}
|
||||
validationError={this.validate(genesetName, genesets)}
|
||||
annoInput={
|
||||
<LabelInput
|
||||
onChange={this.handleChange}
|
||||
inputProps={{
|
||||
"data-testid": "create-geneset-modal",
|
||||
leftIcon: "manually-entered-data",
|
||||
intent: "none",
|
||||
autoFocus: true,
|
||||
}}
|
||||
newLabelMessage="Create gene set"
|
||||
/>
|
||||
}
|
||||
secondaryInstructions="Optionally add a list of comma separated genes to populate the gene set"
|
||||
secondaryInput={
|
||||
<LabelInput
|
||||
onChange={this.handleGenesetInputChange}
|
||||
inputProps={{
|
||||
"data-testid": "add-genes",
|
||||
intent: "none",
|
||||
autoFocus: false,
|
||||
}}
|
||||
newLabelMessage="populate geneset with genes"
|
||||
/>
|
||||
}
|
||||
handleSubmit={this.createGeneset}
|
||||
handleCancel={this.disableCreateGenesetMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CreateGenesetDialogue;
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import AnnoDialog from "../../annoDialog";
|
||||
import LabelInput from "../../labelInput";
|
||||
|
||||
@connect((state) => ({
|
||||
annotations: state.annotations,
|
||||
schema: state.annoMatrix?.schema,
|
||||
ontology: state.ontology,
|
||||
obsCrossfilter: state.obsCrossfilter,
|
||||
genesetsUI: state.genesetsUI,
|
||||
}))
|
||||
class RenameGeneset extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
genesetName: "",
|
||||
};
|
||||
}
|
||||
|
||||
disableEditGenesetNameMode = (e) => {
|
||||
const { dispatch } = this.props;
|
||||
this.setState({
|
||||
genesetName: "",
|
||||
});
|
||||
dispatch({
|
||||
type: "geneset: disable rename geneset mode",
|
||||
});
|
||||
if (e) e.preventDefault();
|
||||
};
|
||||
|
||||
renameGeneset = (e) => {
|
||||
const { dispatch, genesetsUI } = this.props;
|
||||
const { genesetName } = this.state;
|
||||
|
||||
dispatch({
|
||||
type: "geneset: update",
|
||||
genesetName: genesetsUI.isEditingGenesetName,
|
||||
update: { genesetName },
|
||||
});
|
||||
dispatch({
|
||||
type: "geneset: disable rename geneset mode",
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
genesetNameError = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
this.setState({ genesetName: e });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { genesetName } = this.state;
|
||||
const { genesetsUI } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnoDialog
|
||||
isActive={genesetsUI.isEditingGenesetName}
|
||||
inputProps={{
|
||||
"data-testid": `${genesetsUI.isEditingGenesetName}:rename-geneset-dialog`,
|
||||
}}
|
||||
primaryButtonProps={{
|
||||
"data-testid": `${genesetsUI.isEditingGenesetName}:submit-geneset`,
|
||||
}}
|
||||
title="Rename gene set"
|
||||
instruction={`Rename ${genesetsUI.isEditingGenesetName}`}
|
||||
cancelTooltipContent="Close this dialog without renaming the gene set."
|
||||
primaryButtonText="Rename gene set"
|
||||
text={genesetName}
|
||||
validationError={genesetsUI.isEditingGenesetName === genesetName}
|
||||
annoInput={
|
||||
<LabelInput
|
||||
onChange={this.handleChange}
|
||||
inputProps={{
|
||||
"data-testid": "rename-geneset-modal",
|
||||
leftIcon: "manually-entered-data",
|
||||
intent: "none",
|
||||
autoFocus: true,
|
||||
}}
|
||||
newLabelMessage="Rename gene set"
|
||||
/>
|
||||
}
|
||||
handleSubmit={this.renameGeneset}
|
||||
handleCancel={this.disableEditGenesetNameMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RenameGeneset;
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Position,
|
||||
Tooltip,
|
||||
Icon,
|
||||
PopoverInteractionKind,
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
import * as globals from "../../../globals";
|
||||
import AddGeneToGenesetDialogue from "./addGeneToGenesetDialogue";
|
||||
|
||||
@connect((state) => {
|
||||
return {
|
||||
genesetsUI: state.genesetsUI,
|
||||
};
|
||||
})
|
||||
class GenesetMenus extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
activateAddGeneToGenesetMode = () => {
|
||||
const { dispatch, geneset } = this.props;
|
||||
dispatch({
|
||||
type: "geneset: activate add new genes mode",
|
||||
geneset,
|
||||
});
|
||||
};
|
||||
|
||||
activateEditGenesetNameMode = () => {
|
||||
const { dispatch, geneset } = this.props;
|
||||
|
||||
dispatch({
|
||||
type: "geneset: activate rename geneset mode",
|
||||
data: geneset,
|
||||
});
|
||||
};
|
||||
|
||||
handleDeleteCategory = () => {
|
||||
const { dispatch, geneset } = this.props;
|
||||
dispatch({ type: "geneset: delete", genesetName: geneset });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { geneset, genesetsEditable, createText } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{genesetsEditable ? (
|
||||
<>
|
||||
<Tooltip
|
||||
content={createText}
|
||||
position={Position.BOTTOM}
|
||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||
>
|
||||
<Button
|
||||
style={{ marginLeft: 0, marginRight: 2 }}
|
||||
data-testclass="handleAddNewLabelToCategory"
|
||||
data-testid={`${geneset}:add-new-label-to-category`}
|
||||
icon={<Icon icon="plus" iconSize={10} />}
|
||||
onClick={this.activateAddGeneToGenesetMode}
|
||||
small
|
||||
minimal
|
||||
/>
|
||||
</Tooltip>
|
||||
<AddGeneToGenesetDialogue geneset={geneset} />
|
||||
<Popover
|
||||
interactionKind={PopoverInteractionKind.HOVER}
|
||||
boundary="window"
|
||||
position={Position.BOTTOM}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="edit"
|
||||
data-testclass="activateEditGenesetNameMode"
|
||||
data-testid={`${geneset}:edit-genesetName-mode`}
|
||||
onClick={this.activateEditGenesetNameMode}
|
||||
text="Edit gene set name"
|
||||
/>
|
||||
<MenuItem
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
data-testclass="handleDeleteCategory"
|
||||
data-testid={`${geneset}:delete-category`}
|
||||
onClick={this.handleDeleteCategory}
|
||||
text="Delete this gene set (destructive, will remove set and collection of genes)"
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
style={{ marginLeft: 0, marginRight: 5 }}
|
||||
data-testclass="seeActions"
|
||||
data-testid={`${geneset}:see-actions`}
|
||||
icon={<Icon icon="more" iconSize={10} />}
|
||||
small
|
||||
minimal
|
||||
/>
|
||||
</Popover>
|
||||
<Button
|
||||
disabled
|
||||
style={{ marginLeft: 0 }}
|
||||
data-testclass="colorby-entire-geneset"
|
||||
data-testid={`${geneset}:colorby-entire-geneset`}
|
||||
icon={<Icon icon="tint" iconSize={16} />}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GenesetMenus;
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import * as d3 from "d3";
|
||||
import { connect, shallowEqual } from "react-redux";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
|
||||
|
||||
import * as d3 from "d3";
|
||||
import { Colors } from "@blueprintjs/core";
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import * as d3 from "d3";
|
||||
import Lasso from "./setupLasso";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import Categorical from "../categorical";
|
||||
import * as globals from "../../globals";
|
||||
import DynamicScatterplot from "../scatterplot/scatterplot";
|
||||
import TopLeftLogoAndTitle from "./topLeftLogoAndTitle";
|
||||
import Continuous from "../continuous/continuous";
|
||||
|
||||
@connect((state) => ({
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
@@ -32,6 +32,7 @@ class LeftSideBar extends React.Component {
|
||||
}}
|
||||
>
|
||||
<Categorical />
|
||||
<Continuous />
|
||||
</div>
|
||||
{scatterplotXXaccessor && scatterplotYYaccessor ? (
|
||||
<DynamicScatterplot />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { Button, Menu, MenuItem, Popover, Position } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import Continuous from "../continuous/continuous";
|
||||
import GeneExpression from "../geneExpression";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
@@ -22,10 +20,10 @@ class RightSidebar extends React.Component {
|
||||
overflowY: "inherit",
|
||||
height: "inherit",
|
||||
width: "inherit",
|
||||
padding: globals.leftSidebarSectionPadding,
|
||||
}}
|
||||
>
|
||||
<GeneExpression />
|
||||
<Continuous />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
export const margin = { top: 40, right: 5, bottom: 20, left: 60 };
|
||||
export const width = 340;
|
||||
export const height = 340 - margin.top - margin.bottom;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { cloneElement } from "react";
|
||||
import { Tooltip, Position } from "@blueprintjs/core";
|
||||
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||
|
||||
import { tooltipHoverOpenDelayQuick } from "../../globals";
|
||||
|
||||
@@ -86,18 +86,12 @@ export default (props) => {
|
||||
})
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
<Tooltip2
|
||||
content={originalString}
|
||||
hoverOpenDelay={tooltipHoverOpenDelayQuick}
|
||||
position={Position.LEFT}
|
||||
usePortal
|
||||
modifiers={{
|
||||
preventOverflow: { enabled: false },
|
||||
hide: { enabled: false },
|
||||
}}
|
||||
targetProps={{ style: children.props.style }}
|
||||
>
|
||||
{newChildren}
|
||||
</Tooltip>
|
||||
</Tooltip2>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user