Skip to content

Commit 4622c0b

Browse files
calypsomaticeweitz
andauthored
[CORE-654] Allow filtering variants in IGV (#5420)
Co-authored-by: Eric Weitz <[email protected]> Co-authored-by: Eric Weitz <[email protected]>
1 parent 686621d commit 4622c0b

18 files changed

+2452
-294
lines changed

.pnp.cjs

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Binary file not shown.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"react-textarea-autosize": "^8.5.2",
6363
"react-transition-group": "^4.4.5",
6464
"react-virtualized": "patch:react-virtualized@npm:^9.22.5#./.yarn/patches/react-virtualized-9.22.5.patch",
65+
"simple-statistics": "^7.8.8",
6566
"use-memo-one": "^1.1.3",
6667
"uuid": "^8.3.2",
6768
"validate.js": "^0.13.1"
File renamed without changes.

src/components/IGVBrowser.js renamed to src/components/igv/IGVBrowser.js

Lines changed: 229 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import * as clipboard from 'clipboard-polyfill/text';
2+
import debounce from 'lodash/debounce';
23
import _ from 'lodash/fp';
3-
import { Fragment, useRef, useState } from 'react';
4+
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
45
import { div, h } from 'react-hyperscript-helpers';
56
import { ButtonOutline, Link } from 'src/components/common';
67
import { getUserProjectForWorkspace, parseGsUri } from 'src/components/data/data-utils';
78
import { centeredSpinner, icon } from 'src/components/icons';
8-
import IGVAddTrackModal from 'src/components/IGVAddTrackModal';
9+
import IGVAddTrackModal from 'src/components/igv/IGVAddTrackModal';
910
import { GoogleStorage, saToken } from 'src/libs/ajax/GoogleStorage';
1011
import colors from 'src/libs/colors';
1112
import { reportError, withErrorReporting } from 'src/libs/error';
@@ -16,9 +17,14 @@ import { knownBucketRequesterPaysStatuses, requesterPaysProjectStore } from 'src
1617
import * as Utils from 'src/libs/utils';
1718
import { RequesterPaysModal } from 'src/workspaces/common/requester-pays/RequesterPaysModal';
1819

20+
import { buildFilter } from './IGVFilter';
1921
import IGVSessionModal from './IGVSessionModal';
2022
import { updateUrlWithSession, useIGVSessions } from './useIGVSessions';
2123

24+
function getHasVariantFiles(files) {
25+
return files.some((file) => file.filePath.includes('.vcf'));
26+
}
27+
2228
function processUrl(url, isSignedUrl) {
2329
if (url && isGoogleURL(url) && isGoogleStorageURL(url) && isSignedUrl) {
2430
return translateGoogleCloudURL(url);
@@ -27,19 +33,195 @@ function processUrl(url, isSignedUrl) {
2733
}
2834

2935
// format for selectedFiles prop: [{ filePath, indexFilePath, isSignedUrl } }]
30-
const IGVBrowser = ({ selectedFiles, refGenome: { genome, reference }, workspace, onDismiss, initialSession }) => {
36+
const IGVBrowser = ({ selectedFiles, refGenome: { genome, reference }, workspace, onDismiss, initialSession, onFilterPanelChange }) => {
3137
const [loadingIgv, setLoadingIgv] = useState(true);
3238
const [requesterPaysModal, setRequesterPaysModal] = useState(null);
3339
const [showAddTrackModal, setShowAddTrackModal] = useState(false);
3440
const [showSessionModal, setShowSessionModal] = useState(false);
3541
const [sessionAction, setSessionAction] = useState(null); // 'save' or 'load'
3642
const [sharingSession, setSharingSession] = useState(false);
43+
const [filterPanelData, setFilterPanelData] = useState(null);
44+
const currentFilterFunction = useRef(null);
45+
const [tracksLoaded, setTracksLoaded] = useState(false);
46+
const filterPanelDataRef = useRef(null); // Track current panel data
47+
48+
useEffect(() => {
49+
filterPanelDataRef.current = filterPanelData;
50+
}, [filterPanelData]);
51+
52+
const findVariantTrack = useCallback(() => {
53+
if (!igvBrowser.current) return null;
54+
55+
for (const trackView of igvBrowser.current.trackViews) {
56+
const track = trackView.track;
57+
58+
if (track.type?.toLowerCase() === 'variant' || track.format?.toLowerCase() === 'vcf' || track?.url?.toLowerCase().includes('.vcf')) {
59+
return track;
60+
}
61+
}
62+
63+
return null;
64+
}, []);
65+
66+
// When the user changes a filter, update the filter function on the variant track and refresh the view
67+
const handleFilterChange = useCallback(
68+
(selections, facets) => {
69+
if (!igvBrowser.current) return;
70+
71+
const filterFunction = buildFilter(selections, facets);
72+
currentFilterFunction.current = filterFunction;
73+
74+
const trackToFilter = findVariantTrack();
75+
76+
if (trackToFilter) {
77+
trackToFilter.filter = filterFunction;
78+
79+
// Try to refresh the track view
80+
const trackView = igvBrowser.current.trackViews.find((tv) => tv.track === trackToFilter);
81+
if (trackView) {
82+
trackView.repaintViews();
83+
}
84+
} else {
85+
console.error('No variant track found for filtering');
86+
}
87+
},
88+
[findVariantTrack]
89+
);
90+
91+
// When the locus changes, either by moving to a different chromosome or zooming in,
92+
// update the filter panel with the new features in view
93+
// This triggers reinitialization of the filter panel with the new features
94+
const debouncedHandleLocusChange = useRef(
95+
debounce(() => {
96+
const currentPanelData = filterPanelDataRef.current;
97+
98+
if (currentPanelData?.show) {
99+
const trackToFilter = findVariantTrack();
100+
101+
if (trackToFilter && onFilterPanelChange) {
102+
onFilterPanelChange({
103+
...currentPanelData,
104+
isLoading: true,
105+
});
106+
107+
let attempts = 0;
108+
const maxAttempts = 20;
109+
110+
const checkFeaturesLoaded = () => {
111+
const features = trackToFilter.getInViewFeatures();
112+
attempts++;
113+
114+
if (features.length > 0 || attempts >= maxAttempts) {
115+
const updatedPanelData = {
116+
...currentPanelData,
117+
trackToFilter,
118+
isInitialized: false,
119+
currentFacets: [],
120+
currentSelections: {},
121+
isLoading: false,
122+
};
123+
124+
setFilterPanelData(updatedPanelData);
125+
onFilterPanelChange(updatedPanelData);
126+
} else {
127+
setTimeout(checkFeaturesLoaded, 100);
128+
}
129+
};
130+
131+
setTimeout(checkFeaturesLoaded, 100);
132+
}
133+
}
134+
}, 500) // Wait 500ms after the last locus change
135+
).current;
136+
137+
const handleLocusChange = useCallback(() => {
138+
debouncedHandleLocusChange();
139+
}, [debouncedHandleLocusChange]);
140+
141+
// Cleanup debounced function on unmount
142+
useEffect(() => {
143+
return () => {
144+
debouncedHandleLocusChange.cancel();
145+
};
146+
}, [debouncedHandleLocusChange]);
147+
148+
// When the filter panel is opened or closed, notify the parent component to remove/add it from the screen
149+
// When closing, save the data in state so we can reuse it if reopening
150+
const toggleFilterPanel = useCallback(
151+
(show) => {
152+
if (!onFilterPanelChange) return;
153+
154+
if (show) {
155+
if (!igvBrowser.current) return;
156+
157+
const trackToFilter = findVariantTrack();
158+
if (!trackToFilter) return;
159+
160+
const currentPanelData = filterPanelDataRef.current;
161+
162+
// If we already have filter panel data (reopening), reuse it
163+
if (currentPanelData && !currentPanelData.show) {
164+
const updatedPanelData = {
165+
...currentPanelData,
166+
show: true,
167+
trackToFilter,
168+
};
169+
setFilterPanelData(updatedPanelData);
170+
onFilterPanelChange(updatedPanelData);
171+
return;
172+
}
173+
174+
// First time opening - create new panel data
175+
const panelData = {
176+
show: true,
177+
trackToFilter,
178+
onFilterChange: handleFilterChange,
179+
onFacetsUpdate: (facets, selections) => {
180+
setFilterPanelData((prev) => ({
181+
...prev,
182+
currentFacets: facets,
183+
currentSelections: selections,
184+
}));
185+
},
186+
onClose: () => toggleFilterPanel(false),
187+
currentSelections: {},
188+
currentFacets: [],
189+
isInitialized: false,
190+
setIsInitialized: (value) => {
191+
setFilterPanelData((prev) => (prev ? { ...prev, isInitialized: value } : null));
192+
},
193+
isLoading: false,
194+
};
195+
196+
setFilterPanelData(panelData);
197+
onFilterPanelChange(panelData);
198+
} else {
199+
// Closing - keep filterPanelData in state, just hide the panel
200+
const currentPanelData = filterPanelDataRef.current;
201+
if (currentPanelData) {
202+
const hiddenPanelData = {
203+
...currentPanelData,
204+
show: false,
205+
};
206+
setFilterPanelData(hiddenPanelData);
207+
}
208+
onFilterPanelChange({ show: false });
209+
}
210+
},
211+
[handleFilterChange, onFilterPanelChange, findVariantTrack]
212+
);
213+
214+
const handleDismiss = useCallback(() => {
215+
setFilterPanelData(null); // Clear all filter state when IGV closes
216+
onDismiss();
217+
}, [onDismiss]);
37218

38219
const containerRef = useRef();
39220
const igvLibrary = useRef();
40221
const igvBrowser = useRef();
41222
const signal = useCancellation();
42223

224+
const hasVariantFiles = getHasVariantFiles(selectedFiles);
43225
const hasSignedUrl = selectedFiles.some((file) => file.isSignedUrl);
44226

45227
const {
@@ -106,29 +288,34 @@ const IGVBrowser = ({ selectedFiles, refGenome: { genome, reference }, workspace
106288
}
107289
}
108290

109-
_.forEach(({ name, url, indexURL, isSignedUrl }) => {
291+
const loadTrackPromises = tracks.map(({ name, url, indexURL, isSignedUrl }) => {
110292
const [bucket] = parseGsUri(url);
111293
const userProjectParam = { userProject: knownBucketRequesterPaysStatuses.get()[bucket] ? userProject : undefined };
112294

113295
// Omit residual URL parameters from access URLs resolved via DRS Hub
114-
const simpleUrl = _.last(url.split('/')).split('?')[0];
296+
const simpleUrl = url.split('/').at(-1).split('?')[0];
115297

116298
const fullUrl = isSignedUrl ? url : Utils.mergeQueryParams(userProjectParam, url);
117299
const fullIndexUrl = isSignedUrl ? indexURL : indexURL && Utils.mergeQueryParams(userProjectParam, indexURL);
118300

119-
// Enable viewing features upon searching most genes, without needing to zoom several times
120-
const visibilityWindow = 75_000;
301+
// Enable viewing variants for a handful of genes (or a few CNVs), simultaneously;
302+
// or enable viewing other features (e.g. reads) for almost any gene, without zoom
303+
const isVcf = getHasVariantFiles([{ filePath: url }]);
304+
const visibilityWindow = isVcf ? 500_000 : 75_000;
121305

122306
const igvProcessedFullUrl = processUrl(fullUrl, isSignedUrl);
123307
const igvProcessedFullIndexUrl = processUrl(fullIndexUrl, isSignedUrl);
124308

125-
igvBrowser.current.loadTrack({
309+
return igvBrowser.current.loadTrack({
126310
name: name || `${simpleUrl} (${url})`,
127311
url: igvProcessedFullUrl,
128312
indexURL: indexURL ? igvProcessedFullIndexUrl : undefined,
129313
visibilityWindow,
130314
});
131-
}, tracks);
315+
});
316+
317+
await Promise.all(loadTrackPromises);
318+
setTracksLoaded(true);
132319
});
133320

134321
const saveSession = async (sessionName) => {
@@ -195,6 +382,10 @@ const IGVBrowser = ({ selectedFiles, refGenome: { genome, reference }, workspace
195382

196383
igv.setGoogleOauthToken(() => saToken(workspace.workspace.googleProject));
197384
igvBrowser.current = await igv.createBrowser(containerRef.current, options);
385+
window.igvBrowser = igvBrowser.current;
386+
// Update the facet widgets on locus change. Changing the locus changes the features in view. This can be
387+
// relatively frequent, many times a second if dragging the track.
388+
igvBrowser.current.on('locuschange', handleLocusChange);
198389

199390
const initialTracks = _.map(({ filePath, indexFilePath, isSignedUrl }) => {
200391
return { url: filePath, indexURL: indexFilePath, isSignedUrl };
@@ -214,12 +405,25 @@ const IGVBrowser = ({ selectedFiles, refGenome: { genome, reference }, workspace
214405

215406
igvSetup();
216407

217-
return () => !!igvLibrary.current && igvLibrary.current.removeAllBrowsers();
408+
return () => {
409+
if (igvLibrary.current) {
410+
// Remove event listeners before cleanup
411+
igvLibrary.current.removeAllBrowsers();
412+
}
413+
};
218414
});
219415

220416
return h(Fragment, [
221417
div({ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.5rem 0.5rem 0' } }, [
222-
h(Link, { onClick: onDismiss }, [icon('arrowLeft', { style: { marginRight: '1ch' } }), 'Back to data table']),
418+
h(
419+
Link,
420+
{
421+
onClick: () => {
422+
handleDismiss();
423+
},
424+
},
425+
[icon('arrowLeft', { style: { marginRight: '1ch' } }), 'Back to data table']
426+
),
223427
div({ style: { display: 'flex', gap: '0.5rem' } }, [
224428
h(
225429
ButtonOutline,
@@ -261,13 +465,26 @@ const IGVBrowser = ({ selectedFiles, refGenome: { genome, reference }, workspace
261465
},
262466
['Add track']
263467
),
468+
hasVariantFiles
469+
? h(
470+
ButtonOutline,
471+
{
472+
disabled: loadingIgv || !tracksLoaded,
473+
onClick: () => {
474+
toggleFilterPanel(true);
475+
},
476+
style: { marginRight: '5px' },
477+
},
478+
['Filter variants']
479+
)
480+
: null,
264481
]),
265482
]),
266483
div(
267484
{
268485
ref: containerRef,
269486
style: {
270-
overflowY: 'auto',
487+
overflowY: 'visible',
271488
padding: '10px 0',
272489
margin: 8,
273490
border: `1px solid ${colors.dark(0.25)}`,
@@ -284,7 +501,6 @@ const IGVBrowser = ({ selectedFiles, refGenome: { genome, reference }, workspace
284501
addTracks([track]);
285502
},
286503
}),
287-
288504
showSessionModal &&
289505
h(IGVSessionModal, {
290506
action: sessionAction,

0 commit comments

Comments
 (0)