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 <input> 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 = "<svg width='12' height='8' viewBox='0 0 12 8' fill='none' aria-hidden='true'>" + + "<path d='M1 1.5L6 6.5L11 1.5' stroke='currentColor' stroke-width='1.8' " + + "stroke-linecap='round' stroke-linejoin='round'></path></svg>"; + 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 = '<div class="gsHead">' + htmlEncode(heading) + '</div>' + + '<div class="gsEmpty">' + htmlEncode(emptyMsg) + '</div>'; + } else { + var group = null; + rows.forEach(function(r, i) { + var g = r.item.displayCategory || heading; + if (g !== group) { + group = g; + html += '<div class="gsHead">' + htmlEncode(g) + '</div>'; + } + html += '<div class="gsRow' + (i === active ? ' gsActive' : '') + '" data-i="' + i + '">' + + '<span class="gsName">' + htmlEncode(r.name) + '</span>' + + '<span class="gsSpecies">' + htmlEncode(r.species) + '</span>' + + '<span class="gsAcc">' + htmlEncode(r.acc) + '</span></div>'; + }); + } + menu.innerHTML = html; + } + + function openCommon() { + render('Common genomes', commonList(), 'No common genomes configured.'); + menu.classList.add('gsOpen'); + } + + function search(term) { + var t = term.trim(); + if (!t) { openCommon(); return; } + if (t.length < 2) { + // One character is not worth a round trip; filter what is already in hand, which is + // what the jQuery UI version does too. + var hits = commonList().filter(function(d) { + return (d.label || '').toLowerCase().indexOf(t.toLowerCase()) >= 0 || + (d.genome || '').toLowerCase().indexOf(t.toLowerCase()) >= 0; + }); + render('Matching assemblies', hits, 'No assembly matches that name.'); + menu.classList.add('gsOpen'); + return; + } + if (cache[t]) { + render('Matching assemblies', cache[t], 'No assembly matches that name.'); + menu.classList.add('gsOpen'); + return; + } + $.getJSON(searchUrl + encodeURIComponent(t)) + .done(function(res) { + var list = reply(res, t); + cache[t] = list; + if (input.value.trim() === t) { // ignore replies for a term already typed past + render('Matching assemblies', list, 'No assembly matches that name.'); + menu.classList.add('gsOpen'); + } + }) + .fail(function(jqXHR, textStatus, errorThrown) { + var list = (typeof config.onError === 'function') ? + config.onError(jqXHR, textStatus, errorThrown, t) : null; + render('Matching assemblies', list || [], 'No assembly matches that name.'); + menu.classList.add('gsOpen'); + }); + } + + // ---- selection -------------------------------------------------------------------- + function choose(item) { + if (!item || item.disabled || !(item.genome || item.db)) { return; } + committed = item; + input.value = item.label || item.genome || item.db; + userTyped = false; + if (labelElement) { labelElement.innerHTML = item.label || ''; } + if (status) { status.textContent = 'Selected: ' + input.value; } + menu.classList.remove('gsOpen'); + field.classList.remove('gsFocus'); + // Keep the recent-genomes list working exactly as it does for the old widget. + if (typeof addRecentGenome === 'function' && item.db) { addRecentGenome(item); } + renderPicks(); + if (typeof config.onSelect === 'function') { config.onSelect(item, labelElement); } + } + + function renderPicks() { + var n = config.quickPickCount || 5; + var popular = (typeof getPopularGenomes === 'function') ? getPopularGenomes($(input)) : []; + var list = popular.slice(0, n); + // Lead with the most recent assembly that is not already one of the popular pills. + // Two things fall out of picking it that way rather than just taking recent[0]: + // - no duplicate pill. In the dropdown, seeing a genome under both Recent and Popular + // is useful; two identical pills side by side just reads as a bug. + // - the lead pill stops flickering. Choosing a popular assembly moves it to the head + // of the recents, which under recent[0] wiped out the lead pill that was there; + // since that assembly already has its own pill, the first non-popular recent - and + // so the pill - is unchanged. A GenArk assembly stays reachable in one click while + // the user flips between hg38 and hg19. + // Prepending rather than reordering keeps the popular pills in their familiar places. + var recent = (typeof getRecentGenomes === 'function') ? getRecentGenomes() : []; + var inList = function(g) { + return list.some(function(p) { return (p.db || p.genome) === (g.db || g.genome); }); + }; + var top = null; + for (var ri = 0; ri < recent.length; ri++) { + if (!inList(recent[ri])) { top = recent[ri]; break; } + } + if (top) { list = [top].concat(list); } + if (!list.length) { picks.innerHTML = ''; return; } + picks.innerHTML = '<span class="gsPicksLabel">Quick picks:</span>'; + list.forEach(function(d) { + var b = document.createElement('button'); + b.type = 'button'; + // The pill shows the short db name; the full description is the tooltip, which is + // what keeps the row to one line. + b.className = 'gsPick' + (committed && committed.db === d.db ? ' gsPickOn' : ''); + b.textContent = d.db || d.genome; + b.title = (top && d === top ? 'Most recently used: ' : '') + (d.label || ''); + b.addEventListener('click', function() { choose(d); }); + picks.appendChild(b); + }); + } + + function setActive(i) { + if (!rows.length) { return; } + active = Math.max(0, Math.min(i, rows.length - 1)); + Array.prototype.forEach.call(menu.querySelectorAll('.gsRow'), function(el, j) { + el.classList.toggle('gsActive', j === active); + }); + var el = menu.querySelector('.gsRow.gsActive'); + if (el && el.scrollIntoView) { el.scrollIntoView({ block: 'nearest' }); } + } + + // ---- events ------------------------------------------------------------------------- + input.addEventListener('focus', function() { + field.classList.add('gsFocus'); + // Select what is there so typing replaces the assembly name instead of appending to it. + // Deferred, because the click that delivers focus would otherwise collapse the selection. + setTimeout(function() { input.select(); }, 0); + // An empty field, or one still showing the label we put there, means "show me the common + // list"; anything else is something the user typed, so treat it as a query. + if (!input.value.trim() || !userTyped) { + openCommon(); + } else { + search(input.value); + } + }); + input.addEventListener('input', function() { + committed = null; + userTyped = true; + clearTimeout(timer); + var v = input.value; + timer = setTimeout(function() { search(v); }, 300); + }); + input.addEventListener('keydown', function(ev) { + if (ev.key === 'ArrowDown') { ev.preventDefault(); menu.classList.add('gsOpen'); setActive(active + 1); } + else if (ev.key === 'ArrowUp') { ev.preventDefault(); setActive(active - 1); } + else if (ev.key === 'Enter') { + if (menu.classList.contains('gsOpen') && rows[active]) { + ev.preventDefault(); // do not submit the form on the same keystroke + choose(rows[active].item); + } + } + else if (ev.key === 'Escape') { menu.classList.remove('gsOpen'); } + }); + caret.addEventListener('click', function(ev) { + ev.preventDefault(); + if (menu.classList.contains('gsOpen')) { + menu.classList.remove('gsOpen'); + } else { + input.focus(); + openCommon(); + } + }); + menu.addEventListener('mousedown', function(ev) { + // mousedown, not click: the input's blur would otherwise close the menu first. + var row = ev.target.closest ? ev.target.closest('.gsRow') : null; + if (!row) { return; } + ev.preventDefault(); + var r = rows[parseInt(row.getAttribute('data-i'), 10)]; + if (r) { choose(r.item); } + }); + menu.addEventListener('mouseover', function(ev) { + var row = ev.target.closest ? ev.target.closest('.gsRow') : null; + if (row) { setActive(parseInt(row.getAttribute('data-i'), 10)); } + }); + document.addEventListener('click', function(ev) { + if (!wrap.contains(ev.target)) { + menu.classList.remove('gsOpen'); + field.classList.remove('gsFocus'); + } + }); + + renderPicks(); + }); +}