Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tooltip w/ visx customization to volcano plots #381

Merged
merged 11 commits into from
Aug 15, 2023
2 changes: 1 addition & 1 deletion packages/libs/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@visx/text": "^1.3.0",
"@visx/tooltip": "^1.3.0",
"@visx/visx": "^1.1.0",
"@visx/xychart": "^3.1.0",
"@visx/xychart": "https://github.com/jernestmyers/visx.git#visx-xychart",
"bootstrap": "^4.5.2",
"color-math": "^1.1.3",
"d3": "^7.1.1",
Expand Down
28 changes: 28 additions & 0 deletions packages/libs/components/src/plots/VolcanoPlot.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.visx-tooltip {
z-index: 1;
}

.VolcanoPlotTooltip {
padding: 5px 10px;
font-size: 12px;
border-radius: 2px;
box-shadow: 2px 2px 7px rgba(0, 0, 0, 0.5);
}

.VolcanoPlotTooltip > .pseudo-hr {
margin: 5px auto;
height: 1px;
width: 100%;
}

.VolcanoPlotTooltip > ul {
margin: 0;
padding: 0;
list-style: none;
line-height: 1.5em;
font-weight: normal;
}
Copy link
Contributor Author

@jernestmyers jernestmyers Jul 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: I'm just glancing back over this and can't see why this would be necessary. Perhaps I'm misremembering something. If not, I'll remove when I address feedback!

UPDATE: Seems to be necessary, one way or another, to set font-weight: normal. Otherwise, all the content is bold. Will keep it as it is.


.VolcanoPlotTooltip > ul > li > span {
font-weight: bold;
}
70 changes: 66 additions & 4 deletions packages/libs/components/src/plots/VolcanoPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
VolcanoPlotDataPoint,
} from '../types/plots/volcanoplot';
import { NumberRange } from '../types/general';
import { SignificanceColors } from '../types/plots';
import { SignificanceColors, significanceColors } from '../types/plots';
import {
XYChart,
Axis,
Expand All @@ -20,7 +20,9 @@ import {
AnnotationLineSubject,
DataContext,
AnnotationLabel,
Tooltip,
} from '@visx/xychart';
import findNearestDatumXY from '@visx/xychart/lib/utils/findNearestDatumXY';
import { Group } from '@visx/group';
import {
gridStyles,
Expand All @@ -36,6 +38,7 @@ import Spinner from '../components/Spinner';
import { ToImgopts } from 'plotly.js';
import { DEFAULT_CONTAINER_HEIGHT } from './PlotlyPlot';
import domToImage from 'dom-to-image';
import './VolcanoPlot.css';

export interface RawDataMinMaxValues {
x: NumberRange;
Expand Down Expand Up @@ -215,7 +218,6 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref<HTMLDivElement>) {
{/* The XYChart takes care of laying out the chart elements (children) appropriately.
It uses modularized React.context layers for data, events, etc. The following all becomes an svg,
so use caution when ordering the children (ex. draw axes before data). */}

<XYChart
xScale={{
type: 'linear',
Expand All @@ -235,6 +237,7 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref<HTMLDivElement>) {
],
zero: false,
}}
findNearestDatumOverride={findNearestDatumXY}
>
{/* Set up the axes and grid lines. XYChart magically lays them out correctly */}
<Grid numTicks={6} lineStyle={gridStyles} />
Expand Down Expand Up @@ -322,11 +325,70 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref<HTMLDivElement>) {
<Group opacity={markerBodyOpacity}>
<GlyphSeries
dataKey={'data'} // unique key
data={data} // data as an array of obejcts (points). Accessed with dataAccessors
data={data}
{...dataAccessors}
colorAccessor={(d) => d.significanceColor}
colorAccessor={(d: VolcanoPlotDataPoint) => d.significanceColor}
findNearestDatumOverride={findNearestDatumXY}
/>
</Group>
<Tooltip<VolcanoPlotDataPoint>
snapTooltipToDatumX
snapTooltipToDatumY
showVerticalCrosshair
showHorizontalCrosshair
horizontalCrosshairStyle={{ stroke: 'red' }}
verticalCrosshairStyle={{ stroke: 'red' }}
unstyled
applyPositionStyle
renderTooltip={(d) => {
const data = d.tooltipData?.nearestDatum?.datum;
/**
* Notes regarding colors in the tooltips:
* 1. We use the data point's significanceColor property for background color
* 2. For color contrast reasons, color for text and hr's border is set conditionally:
* - if significanceColor matches the 'inconclusive' color (grey), we use black
* - else, we use white
* (white font meets contrast ratio threshold (min 3:1 for UI-y things) w/ #AC3B4E (red) and #0E8FAB (blue))
*/
const color =
data?.significanceColor === significanceColors['inconclusive']
? 'black'
: 'white';
return (
<div
className="VolcanoPlotTooltip"
style={{
color,
background: data?.significanceColor,
}}
>
<ul>
{data?.pointIDs?.map((id) => (
<li key={id}>
<span>{id}</span>
</li>
))}
</ul>
<div
className="pseudo-hr"
style={{ borderBottom: `1px solid ${color}` }}
></div>
<ul>
<li>
<span>log2 Fold Change:</span> {data?.log2foldChange}
</li>
<li>
<span>P Value:</span> {data?.pValue}
</li>
<li>
<span>Adjusted P Value:</span>{' '}
{data?.adjustedPValue ?? 'n/a'}
</li>
</ul>
</div>
);
}}
/>

{/* Truncation indicators */}
{/* Example from https://airbnb.io/visx/docs/pattern */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const Template: Story<TemplateProps> = (args) => {
})
.map((d) => ({
...d,
pointID: d.pointID ? [d.pointID] : undefined,
significanceColor: assignSignificanceColor(
Number(d.log2foldChange),
Number(d.pValue),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const Template: Story<TemplateProps> = (args) => {
})
.map((d) => ({
...d,
pointID: d.pointID ? [d.pointID] : undefined,
significanceColor: assignSignificanceColor(
Number(d.log2foldChange),
Number(d.pValue),
Expand Down
2 changes: 1 addition & 1 deletion packages/libs/components/src/types/plots/volcanoplot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type VolcanoPlotDataPoint = {
// Used for thresholding and tooltip
adjustedPValue?: string;
// Used for tooltip
pointID?: string;
pointIDs?: string[];
// Used to determine color of data point in the plot
significanceColor?: string;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ import DataClient, {
VolcanoPlotRequestParams,
VolcanoPlotResponse,
} from '../../../api/DataClient';
import {
VolcanoPlotData,
VolcanoPlotDataPoint,
} from '@veupathdb/components/lib/types/plots/volcanoplot';
import VolcanoSVG from './selectorIcons/VolcanoSVG';
import { NumberOrDate } from '@veupathdb/components/lib/types/general';
import { DifferentialAbundanceConfig } from '../../computations/plugins/differentialabundance';
Expand Down Expand Up @@ -239,14 +243,12 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
vizConfig.log2FoldChangeThreshold ?? DEFAULT_FC_THRESHOLD;

/**
* Let's filter out data that falls outside of the plot axis ranges and then
* assign a significance color to the visible data
* This version of the data will get passed to the VolcanoPlot component
*/
const finalData = useMemo(() => {
if (data.value && independentAxisRange && dependentAxisRange) {
// Only return data if the points fall within the specified range! Otherwise they'll show up on the plot.
return data.value
const cleanedData = data.value
// Only return data if the points fall within the specified range! Otherwise they'll show up on the plot.
.filter((d) => {
const log2foldChange = Number(d?.log2foldChange);
const transformedPValue = -Math.log10(Number(d?.pValue));
Expand All @@ -257,16 +259,64 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
transformedPValue >= dependentAxisRange.min
);
})
.map((d) => ({
...d,
significanceColor: assignSignificanceColor(
Number(d.log2foldChange),
Number(d.pValue),
significanceThreshold,
log2FoldChangeThreshold,
significanceColors
),
}));
/**
* Okay, this map function is doing a number of things.
* 1. We're going to remove the pointID property and replace it with a pointIDs property that is an array of strings.
* Some data share coordinates but correspond to a different pointID. By converting pointID to pointIDs, we can
* later aggregate data that share coordinates and then render one tooltip that lists all pointIDs corresponding
* to the point on the plot
* 2. We also add a significanceColor property that is assigned a value that gets used in VolcanoPlot when rendering
* the data point and the data point's tooltip. The property is also used in the countsData logic.
*/
.map((d) => {
const { pointID, ...remainingProperties } = d;
return {
...remainingProperties,
pointIDs: pointID ? [pointID] : undefined,
significanceColor: assignSignificanceColor(
Number(d.log2foldChange),
Number(d.pValue),
significanceThreshold,
log2FoldChangeThreshold,
significanceColors
),
};
})
// Sort data in ascending order for tooltips to work most effectively
.sort((a, b) => Number(a.log2foldChange) - Number(b.log2foldChange));

// Here we're going to loop through the cleanedData to aggregate any data with shared coordinates.
// For each entry, we'll check if our aggregatedData includes an item with the same coordinates:
// Yes? => update the matched aggregatedData element's pointID array to include the pointID of the matching entry
// No? => just push the entry onto the aggregatedData array since no match was found
const aggregatedData: VolcanoPlotData = [];
for (const entry of cleanedData) {
const foundIndex = aggregatedData.findIndex(
(d: VolcanoPlotDataPoint) =>
d.log2foldChange === entry.log2foldChange &&
d.pValue === entry.pValue
);
if (foundIndex === -1) {
aggregatedData.push(entry);
} else {
const { pointIDs } = aggregatedData[foundIndex];
if (pointIDs) {
aggregatedData[foundIndex] = {
...aggregatedData[foundIndex],
pointIDs: [
...pointIDs,
...(entry.pointIDs ? entry.pointIDs : []),
],
};
} else {
aggregatedData[foundIndex] = {
...aggregatedData[foundIndex],
pointIDs: entry.pointIDs,
};
}
}
}
return aggregatedData;
}
}, [
data.value,
Expand All @@ -276,7 +326,7 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
log2FoldChangeThreshold,
]);

// For the legend, we need the counts of each assigned significance value
// For the legend, we need the counts of the data
const countsData = useMemo(() => {
if (!finalData) return;
const counts = {
Expand All @@ -285,7 +335,14 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
[significanceColors['low']]: 0,
};
for (const entry of finalData) {
counts[entry.significanceColor]++;
if (entry.significanceColor) {
// Recall that finalData combines data with shared coords into one point in order to display a
// single tooltip that lists all the pointIDs for that shared point. This means we need to use
// the length of the pointID array to accurately reflect the counts of unique data (not unique coords).
const addend = entry.pointIDs?.length ?? 1;
counts[entry.significanceColor] =
addend + counts[entry.significanceColor];
}
}
return counts;
}, [finalData]);
Expand All @@ -294,7 +351,7 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
updateThumbnail,
plotContainerStyles,
[
data,
finalData,
// vizConfig.checkedLegendItems, TODO
vizConfig.independentAxisRange,
vizConfig.dependentAxisRange,
Expand Down
Loading