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:
Colin Megill
2021-03-24 16:33:26 -04:00
committed by GitHub
co-authored by Severiano Badajoz bkmartinjr
parent 5335c39184
commit e6e358ddc8
66 changed files with 1926 additions and 905 deletions
+10 -6
View File
@@ -393,6 +393,8 @@ export const saveGenesetsAction = () => async (dispatch, getState) => {
// bail if gene sets not available, or in readonly mode.
const { config } = state;
const { lastTid, genesets } = state.genesets;
const genesetsAreAvailable =
config?.parameters?.["annotations_genesets"] ?? false;
const genesetsReadonly =
@@ -401,16 +403,18 @@ export const saveGenesetsAction = () => async (dispatch, getState) => {
// our non-save was completed!
return dispatch({
type: "autosave: genesets complete",
lastSavedGenesets: state.genesets,
lastSavedGenesets: genesets,
});
}
const { lastTid, genesets: lastGenesets } = state.genesets;
dispatch({
type: "autosave: genesets started",
});
/* Create the JSON OTA data structure */
const tid = (lastTid ?? 0) + 1;
const genesets = [];
for (const [name, gs] of lastGenesets) {
const genesetsOTA = [];
for (const [name, gs] of genesets) {
const genes = [];
for (const g of gs.genes.values()) {
genes.push({
@@ -418,7 +422,7 @@ export const saveGenesetsAction = () => async (dispatch, getState) => {
gene_description: g.geneDescription,
});
}
genesets.push({
genesetsOTA.push({
geneset_name: name,
geneset_description: gs.genesetDescription,
genes,
@@ -426,7 +430,7 @@ export const saveGenesetsAction = () => async (dispatch, getState) => {
}
const ota = {
tid,
genesets,
genesets: genesetsOTA,
};
/* Save to server */
@@ -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
View File
@@ -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 &&
+1 -1
View File
@@ -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;
+93 -477
View File
@@ -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}
+31 -30
View File
@@ -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
View File
@@ -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;
+62 -43
View File
@@ -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;
@@ -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
View File
@@ -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";
+2 -1
View File
@@ -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 -3
View File
@@ -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;
+3 -9
View File
@@ -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>
);
};
+1
View File
@@ -7,6 +7,7 @@ namespace mangling on these definitions. See webpack config for specifics.
@import "~@blueprintjs/core/lib/css/blueprint.css";
@import "~@blueprintjs/icons/lib/css/blueprint-icons.css";
@import "~@blueprintjs/select/lib/css/blueprint-select.css";
@import "~@blueprintjs/popover2/lib/css/blueprint-popover2.css";
@font-face {
font-family: "Roboto Condensed";
+1 -1
View File
@@ -53,7 +53,7 @@ const Autosave = (
return {
...state,
genesetSaveInProgress: false,
lastSavedGenesets: nextSharedState.genesets,
lastSavedGenesets: nextSharedState.genesets.genesets,
};
}
-1
View File
@@ -1,4 +1,3 @@
// jshint esversion: 6
const Config = (
state = {
displayNames: null,
-2
View File
@@ -1,5 +1,3 @@
// jshint esversion: 6
import _ from "lodash";
import { subsetAndResetGeneLists } from "../util/stateManager/controlsHelpers";
-2
View File
@@ -1,5 +1,3 @@
// jshint esversion: 6
const Differential = (
state = {
diffExp: null,
+1
View File
@@ -200,6 +200,7 @@ const GeneSets = (
*/
case "geneset: add genes": {
const { genesetName, genes } = action;
if (!state.genesets.has(genesetName))
throw new Error("geneset: add genes -- geneset name does not exist.");
+88
View File
@@ -0,0 +1,88 @@
/*
Reducers for geneset UI-state.
*/
const GeneSetsUI = (
state = {
createGenesetModeActive: false,
isEditingGenesetName: false,
isAddingGenesToGeneset: false,
},
action
) => {
switch (action.type) {
/**
* Activate interface for adding a new geneset
* No params, if the action is fired we flip
* a boolean here.
*/
case "geneset: activate add new geneset mode": {
return {
...state,
createGenesetModeActive: true,
};
}
/**
* Disable interface for adding a new geneset
* No params, if the action is fired we flip
* a boolean here.
*/
case "geneset: disable create geneset mode": {
return {
...state,
createGenesetModeActive: false,
};
}
/**
* Activate the interface for adding new genes to a geneset
* isAddingGenesToGeneset {
* geneset: string, name of geneset
* },
*/
case "geneset: activate add new genes mode": {
return {
...state,
isAddingGenesToGeneset: action.geneset,
};
}
/**
* Disable the interface for adding new genes to a geneset
* No params, if the action is fired we flip
* a boolean here.
*/
case "geneset: disable add new genes mode": {
return {
...state,
isAddingGenesToGeneset: false,
};
}
/**
* Activate the interface for renaming a geneset
* isEditingGenesetName: {
* type: "geneset: activate rename geneset mode",
* data: geneset, // a string, name of geneset
* }
*/
case "geneset: activate rename geneset mode": {
return {
...state,
isEditingGenesetName: action.data,
};
}
/**
* Disable the interface for renaming a geneset
* No params, if the action is fired we flip
* a boolean here.
*/
case "geneset: disable rename geneset mode": {
return {
...state,
isEditingGenesetName: false,
};
}
default:
return state;
}
};
export default GeneSetsUI;
+3
View File
@@ -16,6 +16,7 @@ import layoutChoice from "./layoutChoice";
import controls from "./controls";
import annotations from "./annotations";
import genesets from "./genesets";
import genesetsUI from "./genesetsUI";
import autosave from "./autosave";
import ontology from "./ontology";
import centroidLabels from "./centroidLabels";
@@ -33,6 +34,7 @@ const Reducer = undoable(
["ontology", ontology],
["annotations", annotations],
["genesets", genesets],
["genesetsUI", genesetsUI],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
@@ -58,6 +60,7 @@ const Reducer = undoable(
"layoutChoice",
"centroidLabels",
"genesets",
"genesetsUI",
"annotations",
],
undoableConfig
+21
View File
@@ -17,6 +17,8 @@ const skipOnActions = new Set([
"window resize",
"reset colorscale",
"reset centroid labels",
"geneset: initial load",
"geneset: set tid",
"graph brush change",
"continuous metadata histogram brush",
@@ -30,6 +32,9 @@ const skipOnActions = new Set([
"writable obs annotations - save complete",
"writable obs annotations - save started",
"writable obs annotations - save error",
"autosave: genesets started",
"autosave: genesets error",
"autosave: genesets complete",
/* annotation component action */
"annotation: activate add new label mode",
@@ -39,6 +44,14 @@ const skipOnActions = new Set([
"annotation: activate edit label mode",
"annotation: cancel edit label mode",
"set annotations collection name",
/* geneset component action */
"geneset: activate add new geneset mode",
"geneset: disable create geneset mode",
"geneset: activate add new genes mode",
"geneset: disable add new genes mode",
"geneset: activate rename geneset mode",
"geneset: disable rename geneset mode",
]);
/*
@@ -90,6 +103,14 @@ const saveOnActions = new Set([
"annotation: label current cell selection",
"annotation: delete label",
"annotation: category edited",
/* geneset component action */
"geneset: create",
"geneset: delete",
"geneset: update",
"geneset: add genes",
"geneset: delete genes",
"geneset: set gene description",
]);
/**
+13
View File
@@ -0,0 +1,13 @@
/**
* The function will ensure a number is above or below given thresholds
* @param val - a number
* @param rng - an array of two numbers, a min and a max
* Ie., in the case of a histogram brush selection:
* const x0 = x(clamp(selectionRange[0], [min, max]));
* const x1 = x(clamp(selectionRange[1], [min, max]));
* @returns a number
*/
export default function clamp(val, rng) {
return Math.max(Math.min(val, rng[1]), rng[0]);
}
+27
View File
@@ -0,0 +1,27 @@
import significantDigits from "./significantDigits";
/**
* The function will conditionally convert a d3 axis label to scientific notation
* @param x - a d3 axis, ie:
* d3
* .axisBottom(x)
* .ticks(4)
* .tickFormat(d3.format(maybeScientific(x)))
* @returns - the number formatted as scientific, if it's big enough
*/
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;
}
+10
View File
@@ -0,0 +1,10 @@
/**
* The function will split string by comma or space, return unique non-empty strings
* @param geneString - a string of comma delimited genes
* @returns an array
*/
import _ from "lodash";
export default function parseBulkGeneString(geneString) {
return _.pull(_.uniq(geneString.split(/[ ,]+/)), "");
}
-1
View File
@@ -1,4 +1,3 @@
// jshint esversion: 6
import scaleRGB from "./scaleRGB";
// maintain a cache of already parsed RGB names, as it is reasonably expensive
-2
View File
@@ -1,5 +1,3 @@
// jshint esversion: 6
// Substitute for a d3 linear scale - less flexible, more performant.
// Returns a function which will scale a value.
//
-1
View File
@@ -1,4 +1,3 @@
// jshint esversion: 6
export default (input) => {
const outputMax = 1;
const outputMin = 0;
@@ -1,5 +1,3 @@
// jshint esversion: 6
// Interval operations - very simple version of interval set relationship
// operators. An interval is a multi-interval list of [min, max),
// where min and max are mandatory. Constraints:
-2
View File
@@ -1,5 +1,3 @@
// jshint esversion: 6
import { sortIndex } from "./sort";
import { rangeFill as fillRange } from "../range";