4eaf56ff3eb90ea8ebcf1697ac3830b8a7b5465f jrobinso Tue Sep 16 09:32:52 2025 -0700 Handle file picker edge case - picker is open but doesn't have handles for requested files. Also more comments and reformatting. diff --git src/hg/js/igvFileHelper.js src/hg/js/igvFileHelper.js index fa129476844..1184fe71ab9 100644 --- src/hg/js/igvFileHelper.js +++ src/hg/js/igvFileHelper.js @@ -1,29 +1,28 @@ // Helper functions for using igv.js with local files in the UCSC genome browser // // The UCSC browser does not use modules, so wrap code in a self-executing function to limit // scope of variables to this file. (function () { const indexExtensions = new Set(['bai', 'csi', 'tbi', 'idx', 'crai']); const requireIndex = new Set(['bam', 'cram']); // File scope variables const IGV_STORAGE_KEY = "igvSession"; let filePicker = null; let igvBrowser = null; - let igvInitialized = false; let isDragging = false; let sessionAutoSaveTimer = null; // Create a BroadcastChannel for communication between the UCSC browser page and the file picker page. const channel = new BroadcastChannel('igv_file_channel'); // Message types for communication between browser page and file picker page const MSG = { SELECTED_FILES: 'selectedFiles', RESTORE_FILES: 'restoreFiles', REMOVED_TRACK: 'removedTrack', LOAD_URL: 'loadURL', FILE_PICKER_READY: 'filePickerReady', PING: 'ping', PONG: 'pong' @@ -54,58 +53,116 @@ } async arrayBuffer() { this.checkFile(); return this.file.arrayBuffer(); } checkFile() { if (!this.file) { throw new Error(`Connection to file ${this.name} is not available. Please re-select the file.`); } } } + /** + * Initialize igv.js, creating an igvBrowser if a session is found in local storage. The existence of a + * global "igvBrowser" variable indicates igv.js has already been initialized. + * + * @returns {Promise<void>} + */ + async function initIgvUcsc() { + + if (window.igvBrowser) { + //console.log("igvBrowser already exists"); + return; + } + + // Retrieve the igv session for this genome, if any, from local storage. + // TODO -- in the future this might come from the UCSC session (cart) + const db = getDb(); + let sessionString = getSessionStorage()[db]; + + if (sessionString) { + + const igvSession = JSON.parse(igv.uncompressSession(`blob:${sessionString}`)); + + // Reconnect any file-based tracks to the actual File objects. + if (igvSession.tracks) { + + const failed = await restoreTrackConfigurations(igvSession.tracks); + + if (failed.length > 0) { + + const sendRestoreRequest = () => channel.postMessage({type: "restoreFiles", files: failed}); + + if (filePicker && !filePicker.closed) { + sendRestoreRequest(); + return; + } else if (pingFilePicker()) { + + // A file picker is open, but (apparently) doesn't have references to the requested files. + // Send a request to restore the files, which will bring the file picker to the front and + // prompt the user to select the files. + sendRestoreRequest(); + + } else { + + // Open a file picker and prompt user to select files to restore the connections. + filePicker = openFilePicker(); + filePicker.onload = () => { + channel.postMessage({type: "restoreFiles", files: failed}); + }; + } + } + } + await createIGVBrowser(igvSession); + } + } + /** * Given a list of files, return a list of track configurations. Each configuration contains a url (MockFile) and * optionally an indexURL (MockFile). + * + * NOTE: igv uses the "url" and "indexURL" fiels for local files as well as URLs. + * * @param files * @returns {*[]} */ function getTrackConfigurations(files) { // Index look up table, key is the data file name, value is {id, file} const indexLUT = new Map(); // Separate data files from index files const dataFiles = []; for (let {id, file} of files) { const name = file.name; const {dataPath, extension} = getExtension(name); if (indexExtensions.has(extension)) { // key is the data file name const key = dataPath; indexLUT.set(key, {id, file}); } else { dataFiles.push({id, file}); } } - // Now create configurations, matching index files when possible + // Now create configurations, matching index files to corresponding data files when possible const configurations = []; for (let {id, file} of dataFiles) { const filename = file.name; const {extension} = getExtension(filename); if (indexLUT.has(filename)) { const indexURL = indexLUT.get(filename); configurations.push({ id: id, url: new MockFile(id, file), indexURL: new MockFile(indexURL.id, indexURL.file) }); @@ -138,189 +195,128 @@ if ('bai' === extension && !dataPath.endsWith('.bam')) { dataPath = dataPath + '.bam'; } else if ('crai' === extension && !dataPath.endsWith('.cram')) { dataPath = dataPath + '.cram'; } return {dataPath, extension}; } else { return { dataPath: name, extension: '' }; } } + /** + * Called upon a page reload to restore file references in the track configurations. Returns a list of file + * descriptors {id, name} for files that could not be restored. + * + * @param trackConfigurations + * @returns {Promise<*[]>} + */ async function restoreTrackConfigurations(trackConfigurations) { const failed = []; for (let config of trackConfigurations) { if (config.url && 'MockFile' === config.url.type) { const {id, name} = config.url; const file = await restoreFile(id); if (!file) { failed.push({id, name}); } config.url = new MockFile(id, file, name); } if (config.indexURL && 'MockFile' === config.indexURL.type) { const {id, name} = config.indexURL; const file = await restoreFile(id); if (!file) { failed.push({id, name}); } config.indexURL = new MockFile(id, file, name); } } return failed; } /** * Attempt to restore a File object given its id by sending a "getFile" message on the BroadcastChannel and waiting for - * a "file" message in response. If no response is received within 1 second undefined is returned. + * a "file" message in response. If no response is received within 1/2 a second undefined is returned. + * + * NOTE: the 1/2 second timeout is arbitrary, and could be reduced to improve responsiveness. * * @param id * @returns {Promise<unknown>} */ async function restoreFile(id) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const previousOnMessage = channel.onmessage; const timeoutId = setTimeout(() => { cleanup(); console.error(`Timeout waiting for file with id: ${id}`); resolve(undefined); - }, 1000); + }, 500); function cleanup() { channel.onmessage = previousOnMessage; clearTimeout(timeoutId); } channel.onmessage = function (event) { try { const msg = event.data; if (msg.type === 'file') { cleanup(); resolve(msg.data); } } catch (error) { cleanup(); console.error(error); resolve(undefined); } }; channel.postMessage({type: 'getFile', id}); }); } /** * Update the igv.js browser to reflect a change in the UCSC browser start position. This is called - * when the user drags a track image to a new position. It is intended for small changes, and + * when the user drags a UCSC track image to a new position. It is intended for small changes, and * works by shifting the igv.js predrawn track image. This will not work for large position - * changes. + * changes, or for changes in resolution. * * @param newPortalStart */ function updateIgvStartPosition(newPortalStart) { // TODO -- this is hacky, add new function to igv.js if (igvBrowser && !isDragging) { const rf = igvBrowser.referenceFrameList[0]; const d = newPortalStart - 1 - rf.start; rf.shift(d); const allTracks = igvBrowser.findTracks(t => true); for (let track of allTracks) { const viewports = track.trackView.viewports; for (let vp of viewports) { vp.shift(); } } } } - function openFilePicker() { - return window.open('../admin/filePicker.html', 'filePicker' + Date.now(), 'width=600,height=1000'); - } - - // Initialize the embedded IGV browser, restoring state from local storage. - async function initIgvUcsc() { - - console.log("invoking initIgvUcsc"); - - if (window.igvBrowser) { - console.log("igvBrowser already exists"); - return; - } - - if (igvInitialized) { - // Already initialized, do nothing - return; - } - - // Retrieve igv session string from local storage. - // TODO -- in the future this might come from the UCSC session (cart) - - - const db = getDb(); - let sessionString = getSessionStorage()[db]; - - if (sessionString) { - - // Restore the previously saved igv session, if any. - const igvSession = JSON.parse(igv.uncompressSession(`blob:${sessionString}`)); - - // Reconnect any file-based tracks to the actual File objects. - if (igvSession.tracks) { - - const failed = await restoreTrackConfigurations(igvSession.tracks); - - if (failed.length > 0) { - - const sendRestoreRequest = () => channel.postMessage({type: "restoreFiles", files: failed}); - - if (filePicker && !filePicker.closed) { - sendRestoreRequest(); - return; - } - if (filePicker) { - // Unexpected: filePicker reference exists but window is closed. - alert( - `The following file connections could not be restored:\n<ul>${ - failed.map(f => `<li>${f.name}</li>`).join('') - }</ul>\nTo restore the connection select 'Choose Files' and select the files.` - ); - sendRestoreRequest(); - } else { - - // No filePicker, open one - filePicker = openFilePicker(); - filePicker.onload = () => { - channel.postMessage({type: "restoreFiles", files: failed}); - //alert( - // `The following file connections could not be restored:\n<ul>${ - // failed.map(f => `<li>${f}</li>`).join('') - // }</ul>\nTo restore the connection select 'Choose Files' and select the files.` - //) - }; - } - } - } - await createIGVBrowser(igvSession); - } - }; /** * Return the session storage object, which is a dictionary of {genomeID: sessionString} * @returns {any|{}} */ function getSessionStorage() { // Retrieve igv session string from local storage. // TODO -- in the future this might come from the UCSC session (cart) const localStorageString = localStorage.getItem(IGV_STORAGE_KEY); return localStorageString ? JSON.parse(localStorageString) : {}; } /** * Update the igv session in local storage. This is called periodically and on adding tracks. Ideally this * would be called on IGV state change, but we don't have a means to capture all state changes. @@ -384,38 +380,74 @@ // a message on the channel and waiting briefly for a response, but we cannot get a reference to the window // so we ask the user to bring it to the front. const responded = await pingFilePicker(channel); if (responded) { alert("File picker is already open. Please switch to that window."); } else { // No filePicker found, open a new one. filePicker = openFilePicker(); } } }); }); + /** + * Send a "ping" message to the file picker window and wait up to 100 msec for a "pong" response. Used to + * determine if a file picker window is already open. + * @param channel + * @returns {Promise<unknown>} + */ + async function pingFilePicker() { + const waitForResponse = new Promise((resolve) => { + const originalOnMessage = channel.onmessage; + channel.onmessage = (event) => { + if (event.data && event.data.type === "pong") { + channel.onmessage = originalOnMessage; + resolve(true); + } + }; + setTimeout(() => { + channel.onmessage = originalOnMessage; + resolve(false); + }, 100); + }); + + channel.postMessage({type: "ping"}); + + const responded = await waitForResponse; + return responded; + } + + + function openFilePicker() { + return window.open('../admin/filePicker.html', 'filePicker' + Date.now(), 'width=600,height=1000'); + } + /** * Update the track names in the left hand column of the IGV row in the image table. */ function updateTrackNames() { + + document.getElementById('igv_namediv').innerHTML = ""; // Clear any existing content + // Add track names to the left hand column. + if(!igvBrowser) return; + const allTracks = igvBrowser.findTracks(t => t.type); let top = 0; - document.getElementById('igv_namediv').innerHTML = ""; // Clear any existing content for (let track of allTracks) { if ('sequence' !== track.type) { const labelContainer = document.createElement('div'); labelContainer.style.position = 'absolute'; // Use relative positioning labelContainer.style.top = `${top}px`; // Position the element at the current value of "top" labelContainer.style.left = '0'; labelContainer.style.right = '0'; // span full width of the enclosing td via igv_namediv labelContainer.style.width = '100%'; // optional, for clarity const gearDiv = document.createElement('div'); gearDiv.style.position = 'absolute'; gearDiv.style.left = '5px'; gearDiv.style.width = '15px'; gearDiv.style.height = '15px'; @@ -459,90 +491,97 @@ popover.style.left = "-100px"; } const trackLabelDiv = document.createElement('div'); trackLabelDiv.textContent = track.name; // Set the track name as the label trackLabelDiv.style.right = '5px'; // Set a fixed width for the label div trackLabelDiv.style.textAlign = 'right'; // Right-justify the text labelContainer.appendChild(trackLabelDiv); document.getElementById('igv_namediv').appendChild(labelContainer); } top += track.trackView.viewports[0].viewportElement.clientHeight; // Adjust top for the next element } } + /** + * Insert a new row into the image table for the IGV browser. The igv browser object is a super-track of + * sorts, containing all igv.js tracks in the session. In the future we might want to allocate a row + * for each igv.js track, but this will require some refactoring of igv.js. + * + * @returns {HTMLTableRowElement} + */ function insertIGVRow() { const imgTbl = document.getElementById('imgTbl'); const tbody = imgTbl.querySelector('tbody'); const igvRow = document.createElement('tr'); igvRow.id = "tr_igv"; igvRow.innerHTML = ` <td style="background: grey"> <div style="width:13px"></div> </td> <td style="position: relative"> <div id="igv_namediv" style="position:absolute; top:0; bottom:0; left:0; right:0;"></div> </td> <td> <div id="igv_div" style="width: auto"></div> </td> `; tbody.appendChild(igvRow); return igvRow; } /** - * Create an IGV browser instance and insert it into the image table as a new row. The IGV browser essentially becomes + * Create an IGV browser instance and insert it into the image table in a new row. The IGV browser essentially becomes * a track in the UCSC browser context. This function is called when the user adds the first IGV track, or * the igv session is restored on page reload. * * @param config -- The IGV browser configuration object. Must include a reference genome, but might also include * an initial locus or tracks. * @returns {Promise<Browser>} */ async function createIGVBrowser(config) { // Override locus in the IGV session with the UCSC locus // TODO -- should we use genomePos here? const ucscImageWidth = document.getElementById("td_data_ruler").clientWidth; const resolution = (hgTracks.winEnd - hgTracks.winStart) / ucscImageWidth; config.locus = {chr: hgTracks.chromName, start: hgTracks.winStart, bpPerPixel: resolution}; console.log("Creating IGV browser with config: ", config); if (document.getElementById("tr_igv")) { console.warn("IGV track row already exists ???"); // TODO -- how can this happen? return; } const igvRow = insertIGVRow(); - // Ammend the config to remove most of the IGV widgets. We only want the track display area. + // Ammend the igv config to remove most of the IGV widgets. We only want the track display area. Object.assign(config, { showNavigation: false, showIdeogram: false, showRuler: false, - //showSequence: false, + //showSequence: false, // Uncomment this to hide igv sequence track showAxis: false, showTrackDragHandles: false, showAxisColumn: false, gearColumnPosition: 'left', showGearColumn: false, - showTrackLabels: false, - formEmbedMode: true, // triggers key capture in input dialogs - disableZoom: true, - minimumBases: 0 + //showTrackLabels: false, // Uncomment this to hide the "floating div" igv track labels + formEmbedMode: true, // works around hotkey issues affecting igv input elements + disableZoom: true, // disable zooming in igv, use UCSC zoom controls + minimumBases: 0 // No lower limit on bases per pixel, this is controlled by UCSC zoom }); const div = document.getElementById("igv_div"); igvBrowser = await igv.createBrowser(div, config); updateTrackNames(); // Add event handler to remove IGV row from table if all IGV tracks are removed. igvBrowser.on('trackremoved', function (track) { channel.postMessage({type: "removedTrack", config: track.config}); const allTracks = igvBrowser.findTracks(t => "sequence" !== t.type); // ignore sequence track if (allTracks.length === 0) { igvRow.remove(); igvBrowser = null; @@ -559,67 +598,75 @@ isDragging = true; const newStart = igvBrowser.referenceFrameList[0].start; igv.ucscTrackpan(newStart); } ); // Notify UCSC browser that igv.js track panning has ended. igvBrowser.on('trackdragend', () => { isDragging = false; igv.ucscTrackpanEnd(); } ); window.igvBrowser = igvBrowser; + // Start the session auto-save timer. This will periodically save the igv session to local storage. + // When / if we can reliably capture IGV state changes we can eliminate this and just save on state change. startSessionAutoSave(); return igvBrowser; } // Respond to messages from the filePicker window. channel.onmessage = async function (event) { const msg = event.data; if (!msg || !msg.type) return; switch (msg.type) { case MSG.SELECTED_FILES: console.log("Received selected files: ", event.data.files); const configs = getTrackConfigurations(event.data.files); loadIGVTracks(configs); break; case MSG.LOAD_URL: loadIGVTracks([event.data.config]); break; + // NOTE: The file picker ready message does not appear to be used. case MSG.FILE_PICKER_READY: const filesToRestore = JSON.parse(sessionStorage.getItem('filesToRestore')); if (filesToRestore) { channel.postMessage({type: MSG.RESTORE_FILES, files: filesToRestore}); sessionStorage.removeItem('filesToRestore'); } break; } }; + /** + * Load one or more tracks into the igvBrowser. If the igvBrowser does not exist it is created. + * + * @param configs + * @returns {Promise<void>} + */ async function loadIGVTracks(configs) { - if (configs.length > 0) { // Create igvBrowser if needed -- i.e. this is the first track being added. State needs to be obtained // from the UCSC browser for genome and locus. if (typeof (window.igvBrowser) === 'undefined' || window.igvBrowser === null) { const defaultConfig = { reference: await getMinimalReference(getDb()), // locus: genomePos.get() }; igvBrowser = await createIGVBrowser(defaultConfig); } // First search for existing tracks referencing the same files. This is to handle the situation // of a user closing the file picker window, thus loosing file references, then reopening the file picker // to restore them. @@ -634,58 +681,30 @@ if (config.indexURL) { matchingTracks[0].config.indexURL.file = config.indexURL.file; } } else { // This is a new track newConfigs.push(config); } } await igvBrowser.loadTrackList(newConfigs); updateTrackNames(); updateSessionStorage(); } } - /** - * Send a "ping" message to the file picker window and wait up to 100 msec for a "pong" response. Used to - * determine if a file picker window is already open. - * @param channel - * @returns {Promise<unknown>} - */ - async function pingFilePicker(channel) { - const waitForResponse = new Promise((resolve) => { - const originalOnMessage = channel.onmessage; - channel.onmessage = (event) => { - if (event.data && event.data.type === "pong") { - channel.onmessage = originalOnMessage; - resolve(true); - } - }; - setTimeout(() => { - channel.onmessage = originalOnMessage; - resolve(false); - }, 100); - }); - - channel.postMessage({type: "ping"}); - - const responded = await waitForResponse; - return responded; - } - - /* get first line of text from URL */ async function getLine(url, {timeoutMs = 10000} = {}) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); const res = await fetch(url, { headers: {Accept: "text/plain"}, signal: ctrl.signal, }).catch(err => { // surface timeouts/aborts as regular errors throw new Error(`Request failed: ${err.message}`); }); clearTimeout(t); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); @@ -709,36 +728,44 @@ const upOneDirURL = currentURL.substring(0, currentURL.lastIndexOf('/')); const apiUrl = upOneDirURL + `/hubApi?cmd=/list/files;genome=${genomeID};format=text;skipContext=1;fileType=2bit`; try { const twoBitURL = await getLine(apiUrl); return { "id": genomeID, "twoBitURL": twoBitURL, }; } catch (e) { console.error(e); alert("Internal Error: Cannot get 2bit file from " + apiUrl); return null; } } + /** + * Parse a IGV / UCSC style locus string, e.g. "chr1:1000-2000" or "chr1:1000" or "chr1", and return + * a locus object {chr, start, end}. The start is 0-based, end is 1-based. + * + * @param locusString + * @returns {{chr: *, start: (number|number), end: (number|*)}} + */ function parseLocusString(locusString) { const locusRegex = /^([^:]+)(?::(\d+)(?:-(\d+))?)?$/; const match = locusString.match(locusRegex); if (!match) { throw new Error(`Invalid locus string: ${locusString}`); } const chr = match[1]; let start = match[2] ? parseInt(match[2].replace(",", ""), 10) - 1 : 0; // Convert to 0-based let end = match[3] ? parseInt(match[3].replace(",", ""), 10) : start + 100; // Default to 100bp if no end provided if (isNaN(start) || isNaN(end) || start < 0 || end <= start) { throw new Error(`Invalid start or end in locus string: ${locusString}`); } return {chr, start, end}; } // Attach helper functions to the igv object igv.initIgvUcsc = initIgvUcsc; igv.updateIgvStartPosition = updateIgvStartPosition; } -)(); +) +();