47ea57080b515e5dad5f658c58feb8944a7e7d61 chmalee Thu Jan 29 15:30:26 2026 -0800 Replace clade/assembly dropdowns with a search bar on most CGIs. Add a recents list to hgGateway and to the species bar and to the 'Genomes' dropdown menu. Track recently selected species in localStorage. Add toGenome and fromGenome arguemnts to hubApi/liftOver in order to find appropriate liftover assemblies, refs #36232 diff --git src/hg/js/hgGateway.js src/hg/js/hgGateway.js index 185e4dcfb49..a9d15f474df 100644 --- src/hg/js/hgGateway.js +++ src/hg/js/hgGateway.js @@ -852,82 +852,87 @@ var tri = document.getElementById('sliderTriangle'); var strokeWidth = 3, triangleHeight = 6; svg.setAttribute('height', iconHeight); rect.setAttribute('height', iconHeight - strokeWidth); tri.setAttribute('d', 'm 2.5,' + ((iconHeight - triangleHeight) / 2) + ' 0,6 4,-3 z'); $sliderIcon.height(iconHeight); } function initRainbowSlider(svgHeight, stripeColors, stripeTops) { // Once we know the height of the hubs & tree image, initialize the rainbow slider // widget for coordinated scrolling. Dragging the slider causes the image to scroll. // Scrolling the image causes the slider to move. Clicking on a stripe causes the // image to scroll and the slider to move. var $speciesPicker = $('#speciesPicker'); var $sliderBar = $('#rainbowSlider'); - var sliderBarTop = $sliderBar.offset().top; var sliderBarHeight = $speciesPicker.outerHeight(); var $sliderIcon = $('#sliderSvgContainer'); - var sliderIconLeft = $sliderIcon.offset().left; var $speciesTree = $('#speciesTree'); // When the user moves the slider, causing the image to scroll, don't do the // image onscroll action (do that only when the user scrolls the image). var inhibitImageOnScroll = false; // Don't let the slider hang off the bottom when the user clicks the bottom stripe: var maxNormalizedTop = 1 - (sliderBarHeight / svgHeight); // Define several helper functions within this function scope so they can use // the variables defined above. var scrollImage = function(normalizedTop) { // Scroll the hubs+tree image to a normalized top coord scaled by svgHeight. $speciesPicker.scrollTop(svgHeight * normalizedTop); }; var moveSlider = function(normalizedTop) { // Move the slider icon to a normalized top coord scaled by sliderBarHeight. + // Get current offset dynamically since the recents list may have changed size + var sliderBarTop = $sliderBar.offset().top; + var sliderIconLeft = $sliderIcon.offset().left; $sliderIcon.offset({ top: sliderBarTop + (normalizedTop * sliderBarHeight), - left: sliderIconLeft.left }); + left: sliderIconLeft }); }; var onClickStripe = function(normalizedTop) { // The user clicked a stripe; move the slider to the top of that stripe and // scroll the tree image to the top of the corresponding stripe in the image. inhibitImageOnScroll = true; if (normalizedTop > maxNormalizedTop) { normalizedTop = maxNormalizedTop; } scrollImage(normalizedTop); moveSlider(normalizedTop); }; var onDragSlider = function(event, ui) { // The user dragged the slider; scroll the tree image to the corresponding // position. + // Get current offset dynamically since the recents list may have changed size + var sliderBarTop = $sliderBar.offset().top; var sliderTop = ui.offset.top - sliderBarTop; var normalizedTop = sliderTop / sliderBarHeight; inhibitImageOnScroll = true; scrollImage(normalizedTop); }; var onScrollImage = function() { // The user scrolled the image -- or the user did something else which caused // the image to scroll, in which case we don't need to do anything more. var imageTop, normalizedTop; if (inhibitImageOnScroll) { inhibitImageOnScroll = false; return; } + // Get current offset dynamically since the recents list may have changed size + var sliderBarTop = $sliderBar.offset().top; imageTop = -$speciesTree.offset().top + sliderBarTop + 1; normalizedTop = imageTop / svgHeight; moveSlider(normalizedTop); }; // This might be called before the species image has been created; if so, do nothing. if (! $speciesTree || speciesTree.length === 0) { return; } makeRainbowSliderStripes($sliderBar, onClickStripe, svgHeight, stripeColors, stripeTops); resizeSliderIcon($sliderIcon, svgHeight, sliderBarHeight); $sliderIcon.draggable({ axis: 'y', containment: '#speciesGraphic', drag: onDragSlider @@ -1326,39 +1331,35 @@ // Return a concatenation of searchObj list values whose keys start with term // (case-insensitive). var termUpCase = term.toUpperCase(); var searchObjResults = []; _.forEach(searchObj, function(results, key) { if (_.startsWith(key.toUpperCase(), termUpCase)) { searchObjResults = searchObjResults.concat(results); } }); return searchObjResults; } function processSpeciesAutocompleteItems(searchObj, results, term) { // This (bound to searchObj) is passed into autocompleteCat as options.onServerReply. // The server sends a list of items that may include duplicates and can have - // results from dbDb and/or assembly hubs. Also look for results from the - // phylogenetic tree, and insert those before the assembly hub matches. - // Then remove duplicates and return the processed results which will then - // be used to render the menu. - var phyloResults = searchByKeyNoCase(searchObj, term); - var hubResultIx = _.findIndex(results, function(result) { return !! result.hubUrl; }); - var hubResults = hubResultIx >= 0 ? results.splice(hubResultIx) : []; - var combinedResults = results.concat(phyloResults).concat(hubResults); - return removeDups(combinedResults, speciesResultsEquiv); + // results from dbDb and/or assembly hubs. + // Remove duplicates and return the processed results which will then + // be used to render the autocomplete menu only. + var processedResults = removeDups(results, speciesResultsEquiv); + return processedResults; } // Server response event handlers function checkJsonData(jsonData, callerName) { // Return true if jsonData isn't empty and doesn't contain an error; // otherwise complain on behalf of caller. if (! jsonData) { alert(callerName + ': empty response from server'); } else if (jsonData.error) { console.error(jsonData.error); alert(callerName + ': error from server: ' + jsonData.error); } else { if (debugCartJson) { console.log('from server:\n', jsonData); @@ -1379,47 +1380,105 @@ highlightLabel('textEl_' + jsonData.taxId, false); } _.assign(uiState, jsonData); updateFindPositionSection(uiState); if (hubsChanged) { drawSpeciesPicker(prunedDbDbTree); } } function handleRefreshState(jsonData) { if (checkJsonData(jsonData, 'handleRefreshState')) { updateStateAndPage(jsonData); } } + function isGenomeAtFrontOfRecents(db) { + // Check if a genome with this db is already at the front of the recents list. + // This is used to detect if autocompleteCat.js just added it (to avoid double-adding). + // We compare without hub prefixes since autocompleteCat saves without prefix but + // server responses may include prefixes like "hub_123_GCA_xxx". + var stored = window.localStorage.getItem("recentGenomes"); + if (!stored) return false; + var recentObj = JSON.parse(stored); + if (!recentObj.stack || recentObj.stack.length === 0) return false; + var frontKey = trackHubSkipHubName(recentObj.stack[0]); + var checkKey = trackHubSkipHubName(db); + return frontKey === checkKey; + } + function handleSetDb(jsonData) { // Handle the server's response to cartJson command setDb or setHubDb if (checkJsonData(jsonData, 'handleSetDb') && trackHubSkipHubName(jsonData.db) === trackHubSkipHubName(uiState.db)) { updateStateAndPage(jsonData); + // Save to recent genomes only if not already at the front (autocompleteCat.js may have just added it) + // Use db without hub prefix for consistent key comparison + var dbForRecents = trackHubSkipHubName(uiState.db); + if (!isGenomeAtFrontOfRecents(dbForRecents)) { + // Construct a descriptive label that includes the db/accession for identification + var label = uiState.genomeLabel || uiState.genome; + if (label && dbForRecents && label.indexOf(dbForRecents) < 0) { + // Add the db/accession to the label if not already present + label = label + ' (' + dbForRecents + ')'; + } + var recentItem = { + db: dbForRecents, + genome: uiState.genome, + label: label, + taxId: uiState.taxId + }; + if (uiState.hubUrl) { + recentItem.hubUrl = uiState.hubUrl; + // For hub genomes, add category for proper detection when re-selected + if (dbForRecents.startsWith('GCA_') || dbForRecents.startsWith('GCF_')) { + recentItem.category = "UCSC GenArk"; + } + } + addRecentGenome(recentItem); + } + displayRecentGenomesInPanel(); } else { console.log('handleSetDb ignoring: ' + trackHubSkipHubName(jsonData.db) + ' !== ' + trackHubSkipHubName(uiState.db)); } } function handleSetTaxId(jsonData) { // Handle the server's response to the setTaxId cartJson command. if (checkJsonData(jsonData, 'handleSetTaxId') && jsonData.taxId === uiState.taxId) { // Update uiState with new values and update the page: _.assign(uiState, jsonData); updateFindPositionSection(uiState); + // Save to recent genomes only if not already at the front (autocompleteCat.js may have just added it) + // Use db without hub prefix for consistent key comparison + var dbForRecents = trackHubSkipHubName(uiState.db); + if (!isGenomeAtFrontOfRecents(dbForRecents)) { + // Construct a descriptive label that includes the db for identification + var label = uiState.genomeLabel || uiState.genome; + if (label && dbForRecents && label.indexOf(dbForRecents) < 0) { + // Add the db to the label if not already present + label = label + ' (' + dbForRecents + ')'; + } + addRecentGenome({ + db: dbForRecents, + genome: uiState.genome, + label: label, + taxId: uiState.taxId + }); + } + displayRecentGenomesInPanel(); } else { console.log('handleSetTaxId ignoring: ' + jsonData.taxId + ' !== ' + uiState.taxId); } } // UI Event Handlers function clearWatermarkInput($input, watermark) { // Note: it is not necessary to re-.Watermark if we upgrade the plugin to version >= 3.1 $input.css('color', 'black'); $input.val('').Watermark(watermark ,'#686868'); } function clearSpeciesInput() { @@ -1471,45 +1530,71 @@ taxId: '' + taxId } }; cart.send(cmd, handleSetDb); cart.flush(); clearPositionInput(); } highlightLabel('textEl_' + hubName, isAutocomplete); if (! isAutocomplete) { clearSpeciesInput(); } } function setDbFromAutocomplete(item) { // The user has selected a result from the species-search autocomplete. - // It might be a taxId and/or db from dbDb, or it might be a hub db. + // It might be a taxId and/or db from dbDb, or it might be a hub db, + // or a taxon-only result (like "Human") that shows an assembly dropdown. var taxId = item.taxId || -1; var db = item.db; - var org = item.org; - if (typeof item.category !== "undefined" && item.category.startsWith("UCSC GenArk")) { - db = item.genome; - setHubDb(item.hubUrl, taxId, db, "GenArk", item.scientificName, true); - } else if (item.hubUrl) { - // The autocomplete sends the hub database from hubPublic.dbList, + var org = item.org || item.value || item.label; + var cmd; + var genome = item.genome || ''; + + // Check if db is a valid assembly name (not an organism name) + // Valid db names are typically lowercase alphanumeric like "hg38", "mm10" + // Organism names are capitalized like "Human", "Mouse" + var isValidDb = db && /^[a-z]/.test(db) && db !== org; + + // Detect GenArk by category OR by genome name pattern (GCA_/GCF_) + var isGenArk = (typeof item.category !== "undefined" && item.category.startsWith("UCSC GenArk")) || + (item.hubUrl && (genome.startsWith('GCA_') || genome.startsWith('GCF_'))); + + if (isGenArk) { + // For items from localStorage recents, db is the accession; for fresh autocomplete, genome is + db = item.db || item.genome; + setHubDb(item.hubUrl, taxId, db, "GenArk", item.scientificName || org, true); + } else if (item.hubUrl && item.hubName) { + // Public hub - the autocomplete sends the hub database from hubPublic.dbList, // without the hub prefix -- restore the prefix here. db = item.hubName + '_' + item.db; setHubDb(item.hubUrl, taxId, db, item.hubName, org, true); - } else { - setTaxId(taxId, item.db, org, true, false); + } else if (taxId > 0) { + // Native db with valid taxId - pass db only if it's a valid assembly name + setTaxId(taxId, isValidDb ? db : null, org, true, false); + } else if (isValidDb) { + // Native db without taxId - use setDb command directly + cmd = { setDb: { db: db, position: "lastDbPos" } }; + cart.send(cmd, handleSetDb); + cart.flush(); + uiState.db = db; + clearPositionInput(); + clearSpeciesInput(); } + + // Refresh the recent genomes panel (autocompleteCat.js handles saving to localStorage) + displayRecentGenomesInPanel(); } function onClickSpeciesLabel(taxId) { // When user clicks on a label, use that taxId (default db); // don't scroll to the label because if they clicked on it they can see it already; // do clear the autocomplete input. setTaxId(taxId, null, null, false, true); } function onClickHubName(hubUrl, taxId, db, hubName) { // This is just a wrapper -- the draw module has to know all about the contents // of each hub object in hubList anyway. setHubDb(hubUrl, taxId, db, hubName, false); } @@ -1646,72 +1731,141 @@ "Interactive Tutorials"; $("#help > ul")[0].appendChild(tutorialLinks); $("#hgGatewayHelpTutorialLinks").on("click", function () { // Check to see if the tutorial popup has been generated already var tutorialPopupExists = document.getElementById ("tutorialContainer"); if (!tutorialPopupExists) { // Create the tutorial popup if it doesn't exist createTutorialPopup(); } else { //otherwise use jquery-ui to open the popup $("#tutorialContainer").dialog("open"); } }); } + // Recent Genomes Panel functions (Option C layout) + + function renderRecentGenomesPanel(genomes) { + // Render recent genomes as vertical scrollable cards + var $panel = $('#recentGenomesList'); + $panel.empty(); + + if (!genomes || genomes.length === 0) { + $panel.html('