520322178ed1029edc43b6b998023ba84d083301 jrobinso Fri Sep 19 21:36:28 2025 -0700 Better error handling (hopefully) on page refresh. diff --git src/hg/js/igvFileHelper.js src/hg/js/igvFileHelper.js index 3b230f51099..e924a60937c 100644 --- src/hg/js/igvFileHelper.js +++ src/hg/js/igvFileHelper.js @@ -47,31 +47,32 @@ return this.file.slice(start, end); } async text() { this.checkFile(); return this.file.text(); } 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.`); + throw new Error(`Connection to file ${this.name} is not available. Please re-select the file` + + ' in the IGV File Manager window.'); } } } /** * 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} */ async function initIgvUcsc() { if (window.igvBrowser) { //console.log("igvBrowser already exists"); @@ -85,46 +86,46 @@ 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) { + // Filepicker is set. Its not clear how this happens, or if its even possible. sendRestoreRequest(); return; } else if (await 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. + // prompt the user to select the files. -- Prompt provided by igv.js error handler for now. + // showDialog(`Connections to ${failed.length} file(s) in the IGV session could not be ` + + // `restored. Please re-select the files in the IGV File Manager window.`); 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}); - }; + filePicker.onload = () => sendRestoreRequest(); } } } 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 {*[]} @@ -238,56 +239,50 @@ 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/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} */ async function restoreFile(id) { return new Promise((resolve) => { - const previousOnMessage = channel.onmessage; - const timeoutId = setTimeout(() => { - cleanup(); - console.error(`Timeout waiting for file with id: ${id}`); - resolve(undefined); - }, 500); - - function cleanup() { - channel.onmessage = previousOnMessage; - clearTimeout(timeoutId); - } - - channel.onmessage = function (event) { - try { + const fileListener = (event) => { const msg = event.data; - if (msg.type === 'file') { + if (msg.type === 'file' && msg.id === id) { cleanup(); resolve(msg.data); } - } catch (error) { + }; + + const timeout = setTimeout(() => { cleanup(); - console.error(error); + console.error(`Timeout waiting for file with id: ${id}`); resolve(undefined); + }, 500); + + function cleanup() { + channel.removeEventListener('message', fileListener); + clearTimeout(timeout); } - }; + channel.addEventListener('message', fileListener); 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 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, or for changes in resolution. * * @param newPortalStart */ function updateIgvStartPosition(newPortalStart) { // TODO -- this is hacky, add new function to igv.js @@ -340,204 +335,279 @@ * @param intervalMs */ function startSessionAutoSave(intervalMs = 1000) { if (sessionAutoSaveTimer !== null) return; // already running sessionAutoSaveTimer = setInterval(updateSessionStorage, intervalMs); } function stopSessionAutoSave() { if (sessionAutoSaveTimer !== null) { clearInterval(sessionAutoSaveTimer); sessionAutoSaveTimer = null; } } -// Detect a page refresh (visibility change to hidden) and save the session to local storage. This is meant to -// simulate UCSC browser session handling. -// XX TODO - not enough time for sending an HTTP request to update cart - need a better system! -// document.onvisibilitychange = () => { -// if (document.visibilityState === "hidden") { -// if (igvBrowser) { -// //setCartVar("igvState", igvSession, null, false); -// //const igvSession = igvBrowser.compressedSession(); -// //localStorage.setItem("igvSession", igvSession); -// } -// } -// }; + window.addEventListener("DOMContentLoaded", async () => { // The "Add IGV track" button handler. The button opens the file picker window, unless // it is already open in which case it brings that window to the front. Tracks are added // from the filePicker page by selecting track files. - window.addEventListener("DOMContentLoaded", async () => { document.getElementById('hgtIgv').addEventListener('click', async function (e) { e.preventDefault(); // our if (filePicker && !filePicker.closed) { filePicker.focus(); return; } else { - - // A filePicker might be open from a previous instance of this page. We can detect this by sending - // 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. - + // A file picker may be open from a previous session. First ping it to see if it is still there, + // if it responds the user should be alerted from that window, no need to open a new window. const responded = await pingFilePicker(); - if (responded) { - alert("File picker is already open. Please switch to that window."); - } else { - // No filePicker found, open a new one. + if (!responded) { filePicker = openFilePicker(); } } }); + + initializeDialog(); + }); + // Initialize a jQuery UI dialog used for user messages. + function initializeDialog() { + + // Inject a hidden dive for an alert dialog. We use this to report errors. + const alertDialog = document.createElement('div'); + alertDialog.id = 'igvAlertDialog'; + alertDialog.title = 'Alert!'; + alertDialog.style.display = 'none'; + document.body.appendChild(alertDialog); + + $("#igvAlertDialog").dialog({ + autoOpen: false, + modal: true, + position: {my: "center", at: "center", of: $("#imgTbl")} + }); + } + /** * 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} */ async function pingFilePicker() { - const waitForResponse = new Promise((resolve) => { - const originalOnMessage = channel.onmessage; - channel.onmessage = (event) => { + return new Promise((resolve) => { + + const pongListener = (event) => { if (event.data && event.data.type === "pong") { - channel.onmessage = originalOnMessage; + clearTimeout(timeout); + channel.removeEventListener('message', pongListener); resolve(true); } }; - setTimeout(() => { - channel.onmessage = originalOnMessage; + + const timeout = setTimeout(() => { + channel.removeEventListener('message', pongListener); resolve(false); }, 100); - }); + channel.addEventListener('message', pongListener); channel.postMessage({type: "ping"}); - - const responded = await waitForResponse; - return responded; + }); } + /** + * Open the file picker window. If the window is already open it *should* be brought to the front. + * @returns {WindowProxy} + */ function openFilePicker() { - return window.open('../admin/filePicker.html', 'filePicker' + Date.now(), 'width=600,height=1000'); + filePicker = window.open('../admin/filePicker.html', 'igvFilePicker', 'width=600,height=1000'); + if (!filePicker) { + showDialog("Unable to open file picker window. Please disable your popup blocker for this site and try again."); + } + return filePicker; } /** * 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); + const gearIconSize = 10; + const leftSidebar = document.getElementById('igv_leftsidebar'); + const nameDiv = document.getElementById('igv_namediv'); + + // Clear any existing content + nameDiv.innerHTML = ""; + leftSidebar.innerHTML = ""; + + const allTracks = igvBrowser.findTracks(t => t.type); // ignore tracks with no type let top = 0; 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'; - gearDiv.style.maxWidth = '15px'; - gearDiv.style.maxHeight = '15px'; + gearDiv.style.top = `${top + 5}px`; // Slightly offset from the top of the track + gearDiv.style.left = '2px'; + gearDiv.style.width = `${gearIconSize}px`; + gearDiv.style.height = `${gearIconSize}px`; + gearDiv.style.maxWidth = `${gearIconSize}px`; + gearDiv.style.maxHeight = `${gearIconSize}px`; gearDiv.style.overflow = 'hidden'; gearDiv.style.cursor = 'pointer'; - const cog = igv.createIcon('cog', 'grey'); - // Ensure the underlying SVG is 15x15 + const cog = igv.createIcon('cog', 'black'); const svg = cog.tagName && cog.tagName.toLowerCase() === 'svg' ? cog : cog.querySelector('svg'); - if (svg) { - svg.setAttribute('width', '15'); - svg.setAttribute('height', '15'); - svg.style.width = '15px'; - svg.style.height = '15px'; - } + svg.setAttribute('width', gearIconSize.toString()); + svg.setAttribute('height', gearIconSize.toString()); + svg.style.width = `${gearIconSize}px`; + svg.style.height = `${gearIconSize}px`; gearDiv.appendChild(cog); gearDiv.setAttribute('data-track-id', track.id); // Set the track ID attribute - labelContainer.appendChild(gearDiv); + document.getElementById('igv_leftsidebar').appendChild(gearDiv); + + gearDiv.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); const trackId = e.currentTarget.getAttribute('data-track-id'); const matchingTracks = igvBrowser.findTracks(t => t.id === trackId); if (matchingTracks.length > 0) { const trackView = matchingTracks[0].trackView; trackView.trackGearPopup.presentMenuList( trackView, igvBrowser.menuUtils.trackMenuItemList(trackView), igvBrowser.config); } }); // Move the track gear popup location, and hide its true parent. This is a bit hacky, but // moving the gear popover to a new parent has undesired side effects. const popover = track.trackView.trackGearPopup.popover; if (popover) { popover.parentElement.style.width = '0px'; // don't use display=none, that breaks the popup popover.parentElement.style.height = '0px'; popover.style.left = "-100px"; } - + if ('sequence' !== track.type) { 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); + trackLabelDiv.title = (typeof track.url === 'string') ? track.url : track.name; + trackLabelDiv.innerHTML = track.name.replaceAll(".", " "); + trackLabelDiv.style.position = 'absolute'; + trackLabelDiv.style.fontSize = '12px'; + trackLabelDiv.style.color = '#333'; + trackLabelDiv.style.top = `${top + 10}px`; + trackLabelDiv.style.left = '5px'; + trackLabelDiv.style.right = '5px'; + trackLabelDiv.style.height = `{track.trackView.viewports[0].viewportElement.clientHeight}px`; + trackLabelDiv.style.textAlign = 'left'; + trackLabelDiv.style.overflow = 'hidden'; + + nameDiv.appendChild(trackLabelDiv); } 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 = ` - -
- - -
- - -
- - `; + igvRow.classList.add('imgOrd', 'trDraggable'); + igvRow.style.position = 'relative'; // For positioning the overlay + + // First cell for drag handle and left sidebar + const td1 = document.createElement('td'); + td1.className = 'dragHandle'; + td1.style.position = 'relative'; + igvRow.appendChild(td1); + + const leftSidebarDiv = document.createElement('div'); + leftSidebarDiv.id = 'igv_leftsidebar'; + leftSidebarDiv.style.position = "absolute"; + leftSidebarDiv.style.top = "0px"; + leftSidebarDiv.style.bottom = "0px"; + leftSidebarDiv.style.left = "0px"; + leftSidebarDiv.style.right = "0px"; + leftSidebarDiv.style.background = "rgb(193,193,193)"; + td1.appendChild(leftSidebarDiv); + + // Second cell for track names + const td2 = document.createElement('td'); + td2.style.position = 'relative'; + igvRow.appendChild(td2); + + const nameDiv = document.createElement('div'); + nameDiv.id = 'igv_namediv'; + nameDiv.style.position = 'absolute'; + nameDiv.style.top = '0'; + nameDiv.style.bottom = '0'; + nameDiv.style.left = '0'; + nameDiv.style.right = '0'; + td2.appendChild(nameDiv); + + // Third cell for the IGV browser div + const td3 = document.createElement('td'); + igvRow.appendChild(td3); + + const igvDiv = document.createElement('div'); + igvDiv.id = 'igv_div'; + igvDiv.style.width = 'auto'; + td3.appendChild(igvDiv); + + // Create a transparent overlay for the entire row --currently disabled + // const overlay = document.createElement('div'); + // overlay.style.position = 'absolute'; + // overlay.style.top = '0'; + // overlay.style.left = '0'; + // overlay.style.width = '100%'; + // overlay.style.height = '100%'; + // overlay.style.backgroundColor = 'rgba(75, 255, 75, 0.2)'; + // overlay.style.display = 'none'; // Initially hidden + // overlay.style.pointerEvents = 'none'; // Allow clicks to pass through + // igvRow.appendChild(overlay); + // + // // Show/hide overlay on mouseover/mouseout of the left sidebar + // leftSidebarDiv.addEventListener('mouseenter', () => { + // overlay.style.display = 'block'; + // }); + // leftSidebarDiv.addEventListener('mouseleave', () => { + // overlay.style.display = 'none'; + // }); + // nameDiv.addEventListener('mouseenter', () => { + // overlay.style.display = 'block'; + // }); + // nameDiv.addEventListener('mouseleave', () => { + // overlay.style.display = 'none'; + // }); + // + tbody.appendChild(igvRow); return igvRow; } /** * 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} */ async function createIGVBrowser(config) { @@ -554,31 +624,31 @@ return; } const igvRow = insertIGVRow(); // 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, // Uncomment this to hide igv sequence track showAxis: false, showTrackDragHandles: false, showAxisColumn: false, gearColumnPosition: 'left', showGearColumn: false, - //showTrackLabels: false, // Uncomment this to hide the "floating div" igv track labels + 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 @@ -728,44 +798,59 @@ 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; } } + /** + * Opens a modal dialog with a message and custom buttons. + * @param {string} message - The HTML message to display. + * @param {Object} [buttons] - An object where keys are button labels and values are click handler functions. + */ + function showDialog(message, buttons) { + $("#igvAlertDialog").html(message); + const buttonsToShow = buttons || { + "OK": function () { + $(this).dialog("close"); + } + }; + $("#igvAlertDialog").dialog("option", "buttons", buttonsToShow).dialog("open"); + } + + /** * 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; - } -) +}) ();