11import * as clipboard from 'clipboard-polyfill/text' ;
2+ import debounce from 'lodash/debounce' ;
23import _ from 'lodash/fp' ;
3- import { Fragment , useRef , useState } from 'react' ;
4+ import { Fragment , useCallback , useEffect , useRef , useState } from 'react' ;
45import { div , h } from 'react-hyperscript-helpers' ;
56import { ButtonOutline , Link } from 'src/components/common' ;
67import { getUserProjectForWorkspace , parseGsUri } from 'src/components/data/data-utils' ;
78import { centeredSpinner , icon } from 'src/components/icons' ;
8- import IGVAddTrackModal from 'src/components/IGVAddTrackModal' ;
9+ import IGVAddTrackModal from 'src/components/igv/ IGVAddTrackModal' ;
910import { GoogleStorage , saToken } from 'src/libs/ajax/GoogleStorage' ;
1011import colors from 'src/libs/colors' ;
1112import { reportError , withErrorReporting } from 'src/libs/error' ;
@@ -16,9 +17,14 @@ import { knownBucketRequesterPaysStatuses, requesterPaysProjectStore } from 'src
1617import * as Utils from 'src/libs/utils' ;
1718import { RequesterPaysModal } from 'src/workspaces/common/requester-pays/RequesterPaysModal' ;
1819
20+ import { buildFilter } from './IGVFilter' ;
1921import IGVSessionModal from './IGVSessionModal' ;
2022import { updateUrlWithSession , useIGVSessions } from './useIGVSessions' ;
2123
24+ function getHasVariantFiles ( files ) {
25+ return files . some ( ( file ) => file . filePath . includes ( '.vcf' ) ) ;
26+ }
27+
2228function 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