2f73d7595b0e37b159e9c9355f8e99512bf37e81
max
  Wed Sep 10 08:33:03 2025 -0700
Merging Jim Robinsons code into the kent tree. Due to whitespace changes in his IDE, I'm merging this manually.
The original code is at https://github.com/igvteam/ucsc_dev/. changes up to 48816bc are included.
Also adding an API call to get the 2bit file and code to use it.

diff --git src/hg/js/igvFileHelper.js src/hg/js/igvFileHelper.js
new file mode 100644
index 00000000000..852c5dfa13c
--- /dev/null
+++ src/hg/js/igvFileHelper.js
@@ -0,0 +1,667 @@
+// 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;
+
+        // 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'
+        };
+
+        /**
+         * A mock File object that wraps a real File object.  The purpose of this class is to provide a stable
+         * identifier (id) for the file that can be used to restore the File object on page refresh. The object
+         * looks like a File object to igv.js but has an extra "id" attribute.
+         */
+        class MockFile {
+
+            constructor(id, file, name) {
+                this.id = id;
+                this.file = file;
+                this.name = name || (file ? file.name : undefined);
+                this.type = 'MockFile';
+            }
+
+            slice(start, end) {
+                this.checkFile();
+                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.`);
+                }
+            }
+        }
+
+
+        /**
+         * Given a list of files, return a list of track configurations.  Each configuration contains a url (MockFile) and
+         * optionally an indexURL (MockFile).
+         * @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
+            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)
+                    });
+
+                } else if (requireIndex.has(extension)) {
+                    throw new Error(`Unable to load track file ${filename} - you must select both ${filename} and its corresponding index file`);
+                } else {
+                    configurations.push({url: new MockFile(id, file)});
+                }
+
+            }
+            return configurations;
+        }
+
+        /**
+         * Given a file name return the data path (file name without extension) and the extension.  If no extension
+         * is present the extension is the empty string.
+         *
+         * @param name
+         * @returns {{dataPath, extension: string}|{dataPath: string, extension: string}}
+         */
+        function getExtension(name) {
+            const idx = name.lastIndexOf('.');
+
+            if (idx > 0) {
+                let dataPath = name.substring(0, idx);
+                const extension = name.substring(idx + 1);
+
+                // Special case for Picard  file convention
+                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: ''
+                };
+            }
+        }
+
+        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.
+         *
+         * @param id
+         * @returns {Promise<unknown>}
+         */
+        async function restoreFile(id) {
+            return new Promise((resolve, reject) => {
+
+                const previousOnMessage = channel.onmessage;
+                const timeoutId = setTimeout(() => {
+                    cleanup();
+                    console.error(`Timeout waiting for file with id: ${id}`);
+                    resolve(undefined);
+                }, 1000);
+
+                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
+         *  works by shifting the igv.js predrawn track image.  This will not work for large position
+         *  changes.
+         *
+         * @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.
+         */
+        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));
+            }
+        }
+
+        // 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
+        // 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", () => {
+            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.
+
+                    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();
+                    }
+                }
+            });
+        });
+
+
+        /**
+         * Update the track names in the left hand column of the IGV row in the image table.
+         */
+        function updateTrackNames() {
+            // Add track names to the left hand column.
+            const allTracks = igvBrowser.findTracks(t => t.type);
+            let top = 0;
+            document.getElementById('igv_namediv').innerHTML = ""; // Clear any existing content
+            for (let track of allTracks) {
+                const trackLabelDiv = document.createElement('div');
+                trackLabelDiv.setAttribute('data-track-id', track.id); // Set the track ID attribute
+                trackLabelDiv.textContent = track.name; // Set the track name as the label
+                trackLabelDiv.style.marginBottom = '5px'; // Optional: Add spacing between labels
+                trackLabelDiv.style.position = 'absolute'; // Use absolute positioning
+                trackLabelDiv.style.top = `${top}px`; // Position the element at the current value of "top"
+                trackLabelDiv.style.right = '5px'; // Set a fixed width for the label div
+                trackLabelDiv.style.textAlign = 'right'; // Right-justify the text
+                document.getElementById('igv_namediv').appendChild(trackLabelDiv);
+
+                trackLabelDiv.addEventListener('contextmenu', (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);
+                    }
+                });
+
+                top += track.trackView.viewports[0].viewportElement.clientHeight; // Adjust top for the next element
+            }
+        }
+
+        function insertIGVRow() {
+            // Insert the IGV row into the image table.
+            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="width: 140px;position: absolute;top: 0; bottom: 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
+         * 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.
+            Object.assign(config, {
+                showNavigation: false,
+                showIdeogram: false,
+                showRuler: false,
+                //showSequence: false,
+                showAxis: false,
+                showTrackDragHandles: false,
+                showAxisColumn: false,
+                gearColumnPosition: 'left',
+                showGearColumn: false,
+                showTrackLabels: false,
+                disableZoom: true,
+                minimumBases: 0
+            });
+
+            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;
+                    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;
+            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;
+
+                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;
+            }
+        };
+
+        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.
+                const newConfigs = [];
+
+                for (let config of configs) {
+
+                    const matchingTracks = igvBrowser.findTracks(t => config.id === t.id);
+                    if (matchingTracks.length > 0) {
+                        // Just select the first matching track, there should only be one.  Restore its file reference(s).
+                        matchingTracks[0].config.url.file = config.url.file;
+                        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}`);
+
+	  const text = await res.text();
+	  // Return a single line (trim and take first line)
+	  return text.trim().split(/\r?\n/, 1)[0];
+        }
+
+        /**
+         * Return a minimal reference object for the given genomeID. We don't need or want default IGV tracks, only the
+         * reference sequence.
+         *
+         * Eventually expand or reimplement this function to support all UCSC browser genomes.
+         *
+         * @param genomeID
+         * @returns {{id: string, twoBitURL: string}}
+         */
+        async function getMinimalReference(genomeID) {
+            const currentURL = window.location.href;
+            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;
+            }
+        }
+
+        // Attach helper functions to the igv object
+        igv.initIgvUcsc = initIgvUcsc;
+        igv.updateIgvStartPosition = updateIgvStartPosition;
+
+    }
+)();