cee097b75fe4d1b29f0debd8af938ba6bf729d53 max Tue Aug 11 08:18:42 2026 -0700 hgBlat: new client-rendered BLAT search form and sortable results page, refs #37893 An opt-in modern alternative to the classic hyperlink output, gated by the blatNewForm / blatNewPage hg.conf flags. hgBlat.c emits the hit data and form config as inline JSON (hgBlatData / hgBlatFormData) into empty containers, and hgBlat.js builds the UI: the search form, a sortable/filterable DataTable with identity and query-coverage bars, a docked per-hit detail panel, a FASTA query viewer, and share-link and rename-track modals. - hgBlat.css: all styling for both pages, loaded via webIncludeResourceFile (not injected from JS); layout uses CSS classes rather than inline styles. - utils.js: setupGenomeSelector, the combobox genome picker the form uses. - hgc.c: blatOldTracks / blatKeepResults - the "Keep results" checkbox lets a user accumulate BLAT result tracks instead of each search replacing the last. - hgConfCatalog: register the blatNewForm / blatNewFormBanner gates. - blatExample.fa: the example query the form loads on demand. Markup is built in JS (escaped values via htmlEncode, everything else set through DOM properties); no HTML strings in the C or a template engine. diff --git src/hg/js/utils.js src/hg/js/utils.js index da93bb1447a..8a3ab88fa1a 100644 --- src/hg/js/utils.js +++ src/hg/js/utils.js @@ -4528,49 +4528,45 @@ let item = Object.assign({}, recentObj.results[genome]); // Preserve original category for setDbFromAutocomplete to detect GenArk/hubs item.originalCategory = item.category; // Set category for autocomplete grouping header item.category = "Recent"; item.displayCategory = "Recent"; results.push(item); } } return results; } function getPopularGenomes($inputEl) { // Parse the popular assemblies JSON embedded in the page by printGenomeSearchBar(). // Returns an array of autocomplete-formatted items with category "Popular". - // Items already present in recents are excluded to avoid duplicates. + // The Popular list is deliberately NOT filtered against the recents: an assembly may appear + // under both headings. Suppressing the duplicate made entries shift position as soon as one + // was used, so the list a user had learned to click at kept rearranging itself under them. + // A stable Popular list is worth more than avoiding the repeat. let inputId = $inputEl.attr('id'); let dataEl = document.getElementById(inputId + 'PopularData'); if (!dataEl) return []; let popularData; try { popularData = JSON.parse(dataEl.textContent); } catch (e) { return []; } - // Build a set of db names already in recents to avoid duplicates - let recentDbs = new Set(); - let recents = getRecentGenomes(); - for (let r of recents) { - recentDbs.add(r.db || r.genome); - } let results = []; for (let p of popularData) { - if (recentDbs.has(p.db)) continue; results.push({ genome: p.db, db: p.db, label: p.label, commonName: p.commonName, category: "Popular", displayCategory: "Popular" }); } return results; } function removeRecentGenomesByHubUrl(hubUrl) { // Remove all recent genome entries whose hubUrl matches the given URL. // Used when a hub is disconnected to clean up stale entries. @@ -5002,50 +4998,97 @@ * element binding, search button handler, item validation, and label update. * * config object properties: * inputId (required): ID of the search input element * labelElementId: ID of the element to update with selected genome label (default: 'genomeLabel') * onSelect: Callback function(item, labelElement) when genome is selected. * Called AFTER standard validation and label update. * item has: {genome, label, commonName, disabled, ...} * apiUrl: Custom API URL (default: null uses standard hubApi/findGenome) * onServerReply: Custom function to process API results (default: null uses processFindGenome) */ function onSearchError(jqXHR, textStatus, errorThrown, term) { return [{label: 'No genomes found', value: '', genome: '', disabled: true}]; } + // What to put back in the search box if the user opens it and leaves without picking anything. + // Shared between wrappedSelect and the focus/blur pair below so that a genuine selection wins + // over the restore. + let restoreVal = null; + function wrappedSelect(labelElement, item) { // Standard validation - all CGIs check this if (item.disabled || !item.genome) return; // Standard label update - all CGIs do this if (labelElement) labelElement.innerHTML = item.label; + // Make the search box show what was just picked. autoCompleteSelect() in + // autocompleteCat.js blurs the input, and jQuery UI's blur handler restores its last search + // term; when the list came from the recent/popular dropdown that term is the empty string + // (the toggle clears the input before searching), so the box would be left blank after + // choosing a recent genome. Deferring past that blur makes the selection stick. + let selInput = document.getElementById(config.inputId); + if (selInput) { + let text = (item.label || item.value || '').replace(/<[^>]*>/g, ''); + restoreVal = text; // a real pick, so this is what blur should leave behind + setTimeout(function() { selInput.value = text; }, 0); + } // Call user's custom callback for CGI-specific logic if (typeof config.onSelect === 'function') { config.onSelect(item, labelElement); } } document.addEventListener("DOMContentLoaded", () => { let labelElementId = config.labelElementId || 'genomeLabel'; let labelElement = document.getElementById(labelElementId); let boundSelect = wrappedSelect.bind(null, labelElement); initSpeciesAutoCompleteDropdown(config.inputId, boundSelect, config.apiUrl || null, null, config.onServerReply || null, onSearchError); + // Opt-in (config.focusOpensDropdown): behave like a normal search box - focusing it selects + // whatever is in it, so typing replaces the current genome instead of appending to it, and + // opens the suggestion list right away. The source function treats an empty term as + // "show recent + popular", so this needs no special casing. Off by default so the CGIs + // that already use this widget keep their current behaviour. + if (config.focusOpensDropdown) { + let input = document.getElementById(config.inputId); + if (input) { + input.addEventListener("focus", () => { + // Empty the box before searching, exactly as the toggle button does. jQuery + // UI's search() takes its highlight term from the input's own value rather + // than the term passed in, so leaving a full assembly description in place + // bolds fragments of it ("Dec", "2013", ...) across unrelated entries further + // down the list. Clearing also makes this behave like an ordinary search box: + // the caret sits in an empty field ready for typing. + restoreVal = input.value; + input.value = ""; + // Empty term is what the source function treats as "show recent + popular". + $("[id='" + config.inputId + "']").autocompleteCat("search", ""); + }); + input.addEventListener("blur", () => { + // Left without choosing anything: put back whatever was showing before, so the + // bar keeps naming the current assembly. A real pick has already updated + // restoreVal in wrappedSelect, so this restores the new genome, not the old. + if (!input.value && restoreVal) { + input.value = restoreVal; + } + }); + } + } + // Standard search button handler let btn = document.getElementById(config.inputId + "Button"); if (btn) { btn.addEventListener("click", () => { let val = document.getElementById(config.inputId).value; $("[id='" + config.inputId + "']").autocompleteCat("search", val); }); } // Dropdown toggle button: opens/closes the autocomplete with recent+popular let toggle = document.getElementById(config.inputId + "Toggle"); if (toggle) { let wasOpen = false; toggle.addEventListener("mousedown", () => { let $input = $("[id='" + config.inputId + "']"); @@ -5063,15 +5106,379 @@ }); } }); } function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } // check whether an autocomplete item is for a genark assembly function isGenarkItem(item) { return (typeof item.hubUrl !== "undefined" && item.hubUrl) && (typeof item.genome !== "undefined" && item.genome) && (item.genome.startsWith('GCA_') || item.genome.startsWith('GCF_')); } + +// --------------------------------------------------------------------------- +// setupGenomeSelector - combobox replacement for setupGenomeSearchBar +// --------------------------------------------------------------------------- +// Same call signature and same config contract as setupGenomeSearchBar, so a CGI switches over by +// changing only the function name in its setupGenomeSearchBar({...}) call. The markup still comes +// from printGenomeSearchBar() in hg/lib/web.c: this takes over the that emitted, re-houses +// it inside a combobox, and hides that widget's separate toggle / search button / info icon. +// +// Differences from setupGenomeSearchBar, per the Assembly Selector design (option 2a): +// - the caret sits *inside* the field, so it reads as one control with two ways in, rather than +// as a second control competing with the search box +// - the common assemblies appear as a row of short pills under the field, one click each +// - focusing the empty field opens the common list immediately; typing swaps the same panel to +// matching assemblies +// The suggestions themselves come from exactly where they did before: recent genomes from +// localStorage, popular ones from the JSON printGenomeSearchBar embeds, and everything else from +// hubApi/findGenome via processFindGenome. + +var genomeSelectorStyleDone = false; + +function genomeSelectorInjectStyle() { + /* One stylesheet for every selector on the page; injected rather than added to HGStyle.css so + * the component is self-contained and can be dropped into a CGI without a second edit. */ + if (genomeSelectorStyleDone) { return; } + genomeSelectorStyleDone = true; + var css = ` + .gsWrap { position:relative; max-width:620px; } + .gsField { display:flex; align-items:stretch; border:1px solid #9aa6b2; background:#fff; } + .gsField.gsFocus { border-color:#14487f; box-shadow:0 0 0 2px rgba(20,72,127,0.18); } + .gsField input[type=text] { flex:1; min-width:0; border:0; outline:none; padding:11px 12px; + font-size:16px; font-family:inherit; color:#23303f; background:transparent; } + .gsCaret { border:0; border-left:1px solid #dbe1e7; background:#f4f7f9; width:42px; + display:flex; align-items:center; justify-content:center; cursor:pointer; color:#14487f; } + .gsCaret:hover { background:#e7edf2; } + .gsMenu { position:absolute; z-index:20; top:calc(100% + 4px); left:0; right:0; background:#fff; + border:1px solid #c3ccd5; box-shadow:0 8px 24px rgba(20,40,70,0.16); max-height:296px; + overflow:auto; display:none; } + .gsMenu.gsOpen { display:block; } + /* Tighter than the design's spacing: the mockup shows five rows, the real list runs to fifteen + * or more, so the padding that reads as comfortable there turns into a lot of scrolling here. + * Trimming the vertical padding fits roughly a third more rows in the same panel. */ + .gsHead { padding:3px 12px; font-size:11.5px; font-weight:700; letter-spacing:0.06em; + text-transform:uppercase; color:#7c8896; background:#f4f7f9; border-bottom:1px solid #e6eaee; + position:sticky; top:0; } + .gsRow { display:flex; align-items:baseline; gap:8px; padding:3px 12px; cursor:pointer; + border-bottom:1px solid #f0f3f6; line-height:1.4; } + .gsRow.gsActive { background:#eef4fa; } + .gsRow .gsName { font-size:14px; color:#23303f; font-weight:600; } + .gsRow .gsSpecies { font-size:13px; color:#6b7885; font-style:italic; } + .gsRow .gsAcc { margin-left:auto; font-size:12.5px; color:#8b96a2; font-variant-numeric:tabular-nums; } + .gsEmpty { padding:10px 12px; font-size:14px; color:#6b7885; } + .gsPicks { margin-top:10px; display:flex; align-items:center; flex-wrap:wrap; gap:8px; } + .gsPicksLabel { font-size:13px; color:#6b7885; } + /* Smaller than the design's 14px and than the house minimum: these are short, high-contrast db + * names on a secondary row, and keeping them small stops the shortcuts from competing with the + * field above. Weight 600 holds legibility at this size. */ + .gsPick { font-family:inherit; font-size:12.5px; font-weight:600; padding:3px 10px; + border:1px solid #b9c4ce; background:#fff; color:#14487f; cursor:pointer; border-radius:999px; + /* A GenArk db name is a full accession (GCA_018466985.1), far longer than "hg38"; clip it + * rather than let one recent assembly stretch the row. The tooltip has the full name. */ + max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .gsPick:hover { border-color:#14487f; } + .gsPick.gsPickOn { background:#14487f; color:#fff; border-color:#14487f; } + .gsStatus { margin-top:10px; font-size:14px; color:#6b7885; min-height:20px; } + `; + var st = document.createElement('style'); + st.id = 'genomeSelectorStyle'; + st.textContent = css; + document.head.appendChild(st); +} + +function genomeSelectorReady(fn) { + /* Run fn once the DOM is parsed, whether or not DOMContentLoaded has already fired - the CGIs + * emit their setup call at the end of the body, but a later caller should still work. */ + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn); + } else { + fn(); + } +} + +function setupGenomeSelector(config) { +/* Combobox genome/assembly picker. See the block comment above; config is the same object + * setupGenomeSearchBar takes: + * inputId (required), labelElementId, onSelect, apiUrl, onServerReply, onFilterDropdown + * plus, for this selector only: + * quickPickCount - how many pills to show under the field (default 5) + */ + genomeSelectorReady(function() { + var input = document.getElementById(config.inputId); + if (!input) { return; } + genomeSelectorInjectStyle(); + + var labelElement = document.getElementById(config.labelElementId || 'genomeLabel'); + var searchUrl = config.apiUrl || 'hubApi/findGenome?browser=mustExist&q='; + var reply = config.onServerReply || processFindGenome; + var rows = []; // items currently in the menu + var active = -1; // keyboard-highlighted row + var committed = null; // the item last chosen, for highlighting the matching pill + // Has the user typed in the field since it was last filled in for them? If not, whatever + // it contains is a label the page or a selection put there, so focusing means "open the + // common list" rather than "search for this text" - a field pre-filled with + // "Dec. 2013 (GRCh38/hg38)" must not send that whole string to findGenome, which matches + // nothing. A flag rather than comparing against the initial value, because the caller may + // fill the field in after this setup runs (hgBlat does) and any captured value would be stale. + var userTyped = false; + var cache = {}; + var timer = null; + + // ---- build the shell around the existing input ----------------------------------- + var wrap = document.createElement('div'); + wrap.className = 'gsWrap'; + var field = document.createElement('div'); + field.className = 'gsField'; + var caret = document.createElement('button'); + caret.type = 'button'; + caret.className = 'gsCaret'; + caret.setAttribute('aria-label', 'Show common genomes'); + caret.innerHTML = ""; + var menu = document.createElement('div'); + menu.className = 'gsMenu'; + var picks = document.createElement('div'); + picks.className = 'gsPicks'; + // The "Selected: ..." line from the design is opt-in (config.showStatus). The field itself + // already shows the chosen assembly, and callers that want it elsewhere pass labelElementId, + // so by default this would be a redundant line reserving 20px of empty space under the pills. + var status = null; + if (config.showStatus) { + status = document.createElement('div'); + status.className = 'gsStatus'; + } + + // printGenomeSearchBar wraps the input in .searchBarAndButton alongside a toggle, an + // optional search button and an info icon; this control replaces all three, so put the + // combobox where that wrapper was and drop the wrapper. + var oldBar = input.closest('.searchBarAndButton') || input.parentNode; + oldBar.parentNode.insertBefore(wrap, oldBar); + field.appendChild(input); + field.appendChild(caret); + wrap.appendChild(field); + wrap.appendChild(menu); + wrap.parentNode.insertBefore(picks, wrap.nextSibling); + if (status) { picks.parentNode.insertBefore(status, picks.nextSibling); } + oldBar.parentNode.removeChild(oldBar); + input.removeAttribute('size'); + + // ---- data ------------------------------------------------------------------------ + function commonList() { + var recent = (typeof getRecentGenomes === 'function') ? getRecentGenomes() : []; + var popular = (typeof getPopularGenomes === 'function') ? getPopularGenomes($(input)) : []; + var all = recent.concat(popular); + if (typeof config.onFilterDropdown === 'function') { all = config.onFilterDropdown(all); } + return all; + } + + function rowFor(item) { + // The API and the recent/popular lists carry different fields; fall back through them + // so a row always has something in each column rather than a gap. + return { + item: item, + name: item.label || item.genome || item.db || '', + species: item.scientificName || '', + acc: item.db || item.genome || '' + }; + } + + function render(heading, list, emptyMsg) { + // Items carry displayCategory ("Recent", "Popular"); start a new heading whenever it + // changes, so the two are told apart the way the old jQuery UI menu did. Server + // results have no category, so they all fall under the heading passed in. + rows = list.map(rowFor); + active = rows.length ? 0 : -1; + var html = ''; + if (!rows.length) { + html = '