a83239e1585aac37a58288c933d2da9f3e4b8c9f jrobinso Mon Sep 15 20:27:57 2025 -0700 Bug fix -- igv local session was not cleared after removing last track (and consequently igvBrowser. Move igv configuration menu close to cog icon from where it's invoked diff --git src/hg/js/igvFileHelper.js src/hg/js/igvFileHelper.js index eb01d4cfc6c..fa129476844 100644 --- src/hg/js/igvFileHelper.js +++ src/hg/js/igvFileHelper.js @@ -1,30 +1,31 @@ // 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' }; @@ -319,35 +320,53 @@ 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. */ function updateSessionStorage() { if (igvBrowser) { //setCartVar("igvState", igvSession, null, false); const sessionDict = getSessionStorage(); const db = getDb(); sessionDict[db] = igvBrowser.compressedSession(); localStorage.setItem(IGV_STORAGE_KEY, JSON.stringify(sessionDict)); + } else { + localStorage.removeItem(IGV_STORAGE_KEY); + } + } + + /** + * Start a timer to periodically save the igv session to local storage. When / if we are able to + * reliably capture IGV state changes we can eliminate this and just save on state change. + * + * @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; } } - // Periodically update the igv session in local storage. - setInterval(updateSessionStorage, 1); // 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); // } // } // }; // The "Add IGV track" button handler. The button opens the file picker window, unless @@ -391,58 +410,67 @@ 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.overflow = 'hidden'; + gearDiv.style.cursor = 'pointer'; const cog = igv.createIcon('cog', 'grey'); // Ensure the underlying SVG is 15x15 const svg = cog.tagName && cog.tagName.toLowerCase() === 'svg' ? cog : cog.querySelector('svg'); if (svg) { svg.setAttribute('width', '15'); - svg.setAttribute('height', '1'); + svg.setAttribute('height', '15'); svg.style.width = '15px'; svg.style.height = '15px'; } - gearDiv.appendChild(cog); gearDiv.setAttribute('data-track-id', track.id); // Set the track ID attribute labelContainer.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"; + } + 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 } } function insertIGVRow() { const imgTbl = document.getElementById('imgTbl'); const tbody = imgTbl.querySelector('tbody'); @@ -506,52 +534,57 @@ }); 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; + updateSessionStorage(); + stopSessionAutoSave(); // stop auto save timer delete window.igvBrowser; } updateTrackNames(); }); // Add event handler to track igv.js track panning. On the UCSC side this should be treated // as if the user had dragged a track image igvBrowser.on('trackdrag', e => { 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; + + 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);