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);