Skip to content

Commit d67dec7

Browse files
authored
Merge pull request #381 from VEuPathDB/66-volcano-plot-tooltips
Add tooltip w/ visx customization to volcano plots
2 parents 09b6106 + 2c2f178 commit d67dec7

File tree

8 files changed

+3022
-3793
lines changed

8 files changed

+3022
-3793
lines changed

packages/libs/components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"@visx/text": "^1.3.0",
2020
"@visx/tooltip": "^1.3.0",
2121
"@visx/visx": "^1.1.0",
22-
"@visx/xychart": "^3.1.0",
22+
"@visx/xychart": "https://github.com/jernestmyers/visx.git#visx-xychart",
2323
"bootstrap": "^4.5.2",
2424
"color-math": "^1.1.3",
2525
"d3": "^7.1.1",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.visx-tooltip {
2+
z-index: 1;
3+
}
4+
5+
.VolcanoPlotTooltip {
6+
padding: 5px 10px;
7+
font-size: 12px;
8+
border-radius: 2px;
9+
box-shadow: 2px 2px 7px rgba(0, 0, 0, 0.5);
10+
}
11+
12+
.VolcanoPlotTooltip > .pseudo-hr {
13+
margin: 5px auto;
14+
height: 1px;
15+
width: 100%;
16+
}
17+
18+
.VolcanoPlotTooltip > ul {
19+
margin: 0;
20+
padding: 0;
21+
list-style: none;
22+
line-height: 1.5em;
23+
font-weight: normal;
24+
}
25+
26+
.VolcanoPlotTooltip > ul > li > span {
27+
font-weight: bold;
28+
}

packages/libs/components/src/plots/VolcanoPlot.tsx

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
VolcanoPlotDataPoint,
1111
} from '../types/plots/volcanoplot';
1212
import { NumberRange } from '../types/general';
13-
import { SignificanceColors } from '../types/plots';
13+
import { SignificanceColors, significanceColors } from '../types/plots';
1414
import {
1515
XYChart,
1616
Axis,
@@ -20,7 +20,9 @@ import {
2020
AnnotationLineSubject,
2121
DataContext,
2222
AnnotationLabel,
23+
Tooltip,
2324
} from '@visx/xychart';
25+
import findNearestDatumXY from '@visx/xychart/lib/utils/findNearestDatumXY';
2426
import { Group } from '@visx/group';
2527
import {
2628
gridStyles,
@@ -36,6 +38,7 @@ import Spinner from '../components/Spinner';
3638
import { ToImgopts } from 'plotly.js';
3739
import { DEFAULT_CONTAINER_HEIGHT } from './PlotlyPlot';
3840
import domToImage from 'dom-to-image';
41+
import './VolcanoPlot.css';
3942

4043
export interface RawDataMinMaxValues {
4144
x: NumberRange;
@@ -215,7 +218,6 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref<HTMLDivElement>) {
215218
{/* The XYChart takes care of laying out the chart elements (children) appropriately.
216219
It uses modularized React.context layers for data, events, etc. The following all becomes an svg,
217220
so use caution when ordering the children (ex. draw axes before data). */}
218-
219221
<XYChart
220222
xScale={{
221223
type: 'linear',
@@ -235,6 +237,7 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref<HTMLDivElement>) {
235237
],
236238
zero: false,
237239
}}
240+
findNearestDatumOverride={findNearestDatumXY}
238241
>
239242
{/* Set up the axes and grid lines. XYChart magically lays them out correctly */}
240243
<Grid numTicks={6} lineStyle={gridStyles} />
@@ -322,11 +325,70 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref<HTMLDivElement>) {
322325
<Group opacity={markerBodyOpacity}>
323326
<GlyphSeries
324327
dataKey={'data'} // unique key
325-
data={data} // data as an array of obejcts (points). Accessed with dataAccessors
328+
data={data}
326329
{...dataAccessors}
327-
colorAccessor={(d) => d.significanceColor}
330+
colorAccessor={(d: VolcanoPlotDataPoint) => d.significanceColor}
331+
findNearestDatumOverride={findNearestDatumXY}
328332
/>
329333
</Group>
334+
<Tooltip<VolcanoPlotDataPoint>
335+
snapTooltipToDatumX
336+
snapTooltipToDatumY
337+
showVerticalCrosshair
338+
showHorizontalCrosshair
339+
horizontalCrosshairStyle={{ stroke: 'red' }}
340+
verticalCrosshairStyle={{ stroke: 'red' }}
341+
unstyled
342+
applyPositionStyle
343+
renderTooltip={(d) => {
344+
const data = d.tooltipData?.nearestDatum?.datum;
345+
/**
346+
* Notes regarding colors in the tooltips:
347+
* 1. We use the data point's significanceColor property for background color
348+
* 2. For color contrast reasons, color for text and hr's border is set conditionally:
349+
* - if significanceColor matches the 'inconclusive' color (grey), we use black
350+
* - else, we use white
351+
* (white font meets contrast ratio threshold (min 3:1 for UI-y things) w/ #AC3B4E (red) and #0E8FAB (blue))
352+
*/
353+
const color =
354+
data?.significanceColor === significanceColors['inconclusive']
355+
? 'black'
356+
: 'white';
357+
return (
358+
<div
359+
className="VolcanoPlotTooltip"
360+
style={{
361+
color,
362+
background: data?.significanceColor,
363+
}}
364+
>
365+
<ul>
366+
{data?.pointIDs?.map((id) => (
367+
<li key={id}>
368+
<span>{id}</span>
369+
</li>
370+
))}
371+
</ul>
372+
<div
373+
className="pseudo-hr"
374+
style={{ borderBottom: `1px solid ${color}` }}
375+
></div>
376+
<ul>
377+
<li>
378+
<span>log2 Fold Change:</span> {data?.log2foldChange}
379+
</li>
380+
<li>
381+
<span>P Value:</span> {data?.pValue}
382+
</li>
383+
<li>
384+
<span>Adjusted P Value:</span>{' '}
385+
{data?.adjustedPValue ?? 'n/a'}
386+
</li>
387+
</ul>
388+
</div>
389+
);
390+
}}
391+
/>
330392

331393
{/* Truncation indicators */}
332394
{/* Example from https://airbnb.io/visx/docs/pattern */}

packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ const Template: Story<TemplateProps> = (args) => {
112112
})
113113
.map((d) => ({
114114
...d,
115+
pointID: d.pointID ? [d.pointID] : undefined,
115116
significanceColor: assignSignificanceColor(
116117
Number(d.log2foldChange),
117118
Number(d.pValue),

packages/libs/components/src/stories/plots/VolcanoPlotRef.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ const Template: Story<TemplateProps> = (args) => {
7676
})
7777
.map((d) => ({
7878
...d,
79+
pointID: d.pointID ? [d.pointID] : undefined,
7980
significanceColor: assignSignificanceColor(
8081
Number(d.log2foldChange),
8182
Number(d.pValue),

packages/libs/components/src/types/plots/volcanoplot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type VolcanoPlotDataPoint = {
88
// Used for thresholding and tooltip
99
adjustedPValue?: string;
1010
// Used for tooltip
11-
pointID?: string;
11+
pointIDs?: string[];
1212
// Used to determine color of data point in the plot
1313
significanceColor?: string;
1414
};

packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import DataClient, {
3333
VolcanoPlotRequestParams,
3434
VolcanoPlotResponse,
3535
} from '../../../api/DataClient';
36+
import {
37+
VolcanoPlotData,
38+
VolcanoPlotDataPoint,
39+
} from '@veupathdb/components/lib/types/plots/volcanoplot';
3640
import VolcanoSVG from './selectorIcons/VolcanoSVG';
3741
import { NumberOrDate } from '@veupathdb/components/lib/types/general';
3842
import { DifferentialAbundanceConfig } from '../../computations/plugins/differentialabundance';
@@ -239,14 +243,12 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
239243
vizConfig.log2FoldChangeThreshold ?? DEFAULT_FC_THRESHOLD;
240244

241245
/**
242-
* Let's filter out data that falls outside of the plot axis ranges and then
243-
* assign a significance color to the visible data
244246
* This version of the data will get passed to the VolcanoPlot component
245247
*/
246248
const finalData = useMemo(() => {
247249
if (data.value && independentAxisRange && dependentAxisRange) {
248-
// Only return data if the points fall within the specified range! Otherwise they'll show up on the plot.
249-
return data.value
250+
const cleanedData = data.value
251+
// Only return data if the points fall within the specified range! Otherwise they'll show up on the plot.
250252
.filter((d) => {
251253
const log2foldChange = Number(d?.log2foldChange);
252254
const transformedPValue = -Math.log10(Number(d?.pValue));
@@ -257,16 +259,64 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
257259
transformedPValue >= dependentAxisRange.min
258260
);
259261
})
260-
.map((d) => ({
261-
...d,
262-
significanceColor: assignSignificanceColor(
263-
Number(d.log2foldChange),
264-
Number(d.pValue),
265-
significanceThreshold,
266-
log2FoldChangeThreshold,
267-
significanceColors
268-
),
269-
}));
262+
/**
263+
* Okay, this map function is doing a number of things.
264+
* 1. We're going to remove the pointID property and replace it with a pointIDs property that is an array of strings.
265+
* Some data share coordinates but correspond to a different pointID. By converting pointID to pointIDs, we can
266+
* later aggregate data that share coordinates and then render one tooltip that lists all pointIDs corresponding
267+
* to the point on the plot
268+
* 2. We also add a significanceColor property that is assigned a value that gets used in VolcanoPlot when rendering
269+
* the data point and the data point's tooltip. The property is also used in the countsData logic.
270+
*/
271+
.map((d) => {
272+
const { pointID, ...remainingProperties } = d;
273+
return {
274+
...remainingProperties,
275+
pointIDs: pointID ? [pointID] : undefined,
276+
significanceColor: assignSignificanceColor(
277+
Number(d.log2foldChange),
278+
Number(d.pValue),
279+
significanceThreshold,
280+
log2FoldChangeThreshold,
281+
significanceColors
282+
),
283+
};
284+
})
285+
// Sort data in ascending order for tooltips to work most effectively
286+
.sort((a, b) => Number(a.log2foldChange) - Number(b.log2foldChange));
287+
288+
// Here we're going to loop through the cleanedData to aggregate any data with shared coordinates.
289+
// For each entry, we'll check if our aggregatedData includes an item with the same coordinates:
290+
// Yes? => update the matched aggregatedData element's pointID array to include the pointID of the matching entry
291+
// No? => just push the entry onto the aggregatedData array since no match was found
292+
const aggregatedData: VolcanoPlotData = [];
293+
for (const entry of cleanedData) {
294+
const foundIndex = aggregatedData.findIndex(
295+
(d: VolcanoPlotDataPoint) =>
296+
d.log2foldChange === entry.log2foldChange &&
297+
d.pValue === entry.pValue
298+
);
299+
if (foundIndex === -1) {
300+
aggregatedData.push(entry);
301+
} else {
302+
const { pointIDs } = aggregatedData[foundIndex];
303+
if (pointIDs) {
304+
aggregatedData[foundIndex] = {
305+
...aggregatedData[foundIndex],
306+
pointIDs: [
307+
...pointIDs,
308+
...(entry.pointIDs ? entry.pointIDs : []),
309+
],
310+
};
311+
} else {
312+
aggregatedData[foundIndex] = {
313+
...aggregatedData[foundIndex],
314+
pointIDs: entry.pointIDs,
315+
};
316+
}
317+
}
318+
}
319+
return aggregatedData;
270320
}
271321
}, [
272322
data.value,
@@ -276,7 +326,7 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
276326
log2FoldChangeThreshold,
277327
]);
278328

279-
// For the legend, we need the counts of each assigned significance value
329+
// For the legend, we need the counts of the data
280330
const countsData = useMemo(() => {
281331
if (!finalData) return;
282332
const counts = {
@@ -285,7 +335,14 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
285335
[significanceColors['low']]: 0,
286336
};
287337
for (const entry of finalData) {
288-
counts[entry.significanceColor]++;
338+
if (entry.significanceColor) {
339+
// Recall that finalData combines data with shared coords into one point in order to display a
340+
// single tooltip that lists all the pointIDs for that shared point. This means we need to use
341+
// the length of the pointID array to accurately reflect the counts of unique data (not unique coords).
342+
const addend = entry.pointIDs?.length ?? 1;
343+
counts[entry.significanceColor] =
344+
addend + counts[entry.significanceColor];
345+
}
289346
}
290347
return counts;
291348
}, [finalData]);
@@ -294,7 +351,7 @@ function VolcanoPlotViz(props: VisualizationProps<Options>) {
294351
updateThumbnail,
295352
plotContainerStyles,
296353
[
297-
data,
354+
finalData,
298355
// vizConfig.checkedLegendItems, TODO
299356
vizConfig.independentAxisRange,
300357
vizConfig.dependentAxisRange,

0 commit comments

Comments
 (0)