42b33234bee213325c2b6eede35edbb4fb2e566c
max
  Thu Sep 3 06:40:30 2026 -0700
hgBlat: spinner while a search runs, shorter example, left-justified result columns

Four items from Lou's note-37 on #38086.

The search is a plain form POST, so nothing on screen changed between Submit and
the arrival of the results - on an assembly served by a dynamic BLAT server that
is a ten second wait with no sign that anything is happening.  A busy overlay now
covers the form: a generic .gbBusyBg/.gbSpinner in gbModern.css (CSS-drawn, since
the page is already navigating away when it appears), put up by blatFormBusyWire()
350 ms after submit so a sub-second hg38 search does not flash a scrim, and
swapping its text after 8 s to say that an index is probably being loaded on
demand.  Skipped for Clear and for an empty query; a pageshow handler takes it
down again after a Back navigation.

The example sequence goes from 13.8 kb of the SOD1 locus to 2.5 kb of PTP4A3,
hg38 chr8:141,424,501-141,427,000.  That window is carried by both
chr8_KI270819v1_alt and chr8_MU273363v1_fix, so the example now demonstrates the
alt and fix rows and their FAQ icons instead of returning one plain hit, and it
covers two PTP4A3 exons so the result track is named after the gene.

Every column of the results table is left-justified; the numeric ones keep
tabular figures, and the score value gets a fixed width so its bars stay in a
straight line.  The alt/fix explanation icon is red rather than faint grey, and
is drawn as the browser's own info-icon SVG rather than the U+24D8 glyph, which
some system fonts do not have.

refs #38086

diff --git src/hg/js/hgBlat.js src/hg/js/hgBlat.js
index 092cbb5bd71..b6c5b57992c 100644
--- src/hg/js/hgBlat.js
+++ src/hg/js/hgBlat.js
@@ -20,33 +20,38 @@
 
 function blatIdColor(id) {
     // UCSC identity semantic colors
     if (id >= 98) { return '#1f7a34'; }
     if (id >= 95) { return '#4d7c0f'; }
     if (id >= 90) { return '#b45309'; }
     return '#b1301f';
 }
 
 // ---- cell renderers ------------------------------------------------------
 
 function blatPositionCell(hit) {
     // For alt/fix/random/chrUn sequences show an info icon linking to the FAQ ("What is chr_alt &
     // chr_fix?"), with the short explanation as its tooltip.  (Sits after the position link, not
     // nested inside it.)
+    // Drawn as the browser's own info-icon SVG rather than the ⓘ glyph it used to be: the
+    // glyph is missing from some system fonts (it renders as a tofu box), and an SVG can take the
+    // red that makes it stand out in the row (Lou, #38086 note-37).  currentColor lets .chrNote in
+    // hgBlat.css own both the resting and the hover colour.
     var note = hit.chromNote ?
         ` <a class="chrNote" target="_blank" href="../FAQ/FAQblat.html#blat1c" ` +
-        `title="${htmlEncode(hit.chromNote)} Click to learn more in the BLAT FAQ.">&#9432;</a>` : '';
+        `title="${htmlEncode(hit.chromNote)} Click to learn more in the BLAT FAQ.">` +
+        `${blatInfoSvg('currentColor')}</a>` : '';
     // The position links to the Genome Browser at this match; the new-tab icon right after it opens
     // the same in a new tab (whitespace between them, no divider).
     // URLs are htmlEncode'd before going into href="": they can carry the user's query name, so an
     // unescaped double-quote would otherwise break out of the attribute (XSS).
     return `<a class="blatPos" title="Open the Genome Browser at this location" ` +
         `href="${htmlEncode(hit.browserUrl)}">${htmlEncode(hit.chrom)}:` +
         `${blatFmt(hit.tStart)}-${blatFmt(hit.tEnd)}</a>` +
         ` <a class="blatNewTab" target="_blank" href="${htmlEncode(hit.newTabUrl)}" ` +
         `title="Open match in a new tab" aria-label="Open match in a new tab"></a>${note}`;
 }
 
 function blatActionsCell(hit) {
     // The "Open" column now holds just the base-by-base alignment link (Browser moved to the Position
     // column).  detailsUrl is htcUserAli on a fresh search, htcBlatAlign on a shared-link reopen; guard
     // in case a future caller omits it.
@@ -488,86 +493,86 @@
 
     // Top banner: note this is the new page, link back to the classic page (fresh searches only,
     // where the trash files still exist), and invite feedback.  The old page also clears the
     // blatNewPage preference so later searches use the classic page until the user opts back in.
     var origPage = cfg.canOldPage ?
         ` You can go back to <a title="Show these results on the classic BLAT results page" ` +
         `href="hgBlat?blatNewPage=0&blatReopen=1&hgsid=${encodeURIComponent(cfg.hgsid)}">the original page</a> anytime.` : '';
     var bannerHtml =
         `<div class="gbBanner">We are testing a new BLAT output page.${origPage} ` +
         `If you have feedback on this new page, do not hesitate to let us know via ` +
         `<a href="mailto:genome@soe.ucsc.edu">genome@soe.ucsc.edu</a>.</div>`;
 
     var queryCount = new Set(hits.map(h => h.qName)).size;
 
     var th = [];
-    th.push('<th class="num">#</th>');
+    th.push('<th>#</th>');
     if (cfg.multiQuery) { th.push('<th>Query</th>'); }
     th.push('<th>Open in Genome Browser</th>');
     th.push('<th>Show</th>');
     th.push('<th>Query coverage</th>');
     if (cfg.hasLocus) { th.push('<th>Locus</th>'); }
-    th.push('<th class="num">Score</th>');
-    th.push('<th class="num">Identity</th>');
+    th.push('<th>Score</th>');
+    th.push('<th>Identity</th>');
     th.push('<th>Strand</th>');
-    th.push('<th class="num">Span</th>');
+    th.push('<th>Span</th>');
 
     // detail dock sits above the table: with long hit lists a bottom dock scrolls out of view
     document.getElementById('blatResults').innerHTML =
         bannerHtml +
         `<div class="gbCard">${blatSummaryStrip(cfg, queryCount)}` +
         `<div id="gbShareBox" class="gbShareBox" style="display:none"></div>` +
         `<div id="blatSeqBox" class="gbShareBox" style="display:none"></div>` +
         `<div id="blatDetail" class="blatDetail"></div>` +
         `<table id="blatTable" class="display"><thead><tr>${th.join('')}</tr></thead></table></div>` +
         (cfg.canRename ? blatRenameModalHtml(cfg) : '');
 
     // Put the page actions in the gold main-header bar, to the right of the title (framework #sectTtl).
     var sectTtl = document.getElementById('sectTtl');
     if (sectTtl) {
         var acts = document.createElement('span');
         acts.className = 'blatHeadActions';
         acts.innerHTML = headActions;
         sectTtl.appendChild(acts);
     }
 
     $('#blatShareBtn').on('click', blatShareLink);
     $('#blatSeqBtn').on('click', blatShowQuerySeq);
     blatWireRename();
 
     var columns = [];
-    columns.push({ data: 'rank', className: 'num rankCol' });
+    columns.push({ data: 'rank', className: 'rankCol' });
     if (cfg.multiQuery) { columns.push({ data: 'qName', className: 'queryCol' }); }
     columns.push({ data: null, orderable: false, className: 'blatPos',
         render: (d, type, row) => (type === 'display' ? blatPositionCell(row) : row.chrom + ':' + row.tStart) });
     columns.push({ data: null, orderable: false, className: 'actionsCol',
         render: (d, type, row) => (type === 'display' ? blatActionsCell(row) : '') });
     columns.push({ data: null, className: 'covCol', orderable: false,
         render: (d, type, row) => (type === 'display' ? blatCoverageCell(row) :
             (row.qEnd - row.qStart + 1)) });
     if (cfg.hasLocus) {
         columns.push({ data: 'locusText',
             render: (d, type, row) => (type === 'display' ? blatLocusCell(row) : (d || '')) });
     }
     // Score carries a bar scaled to the highest score in this result set (raw score kept for sorting).
     var maxScore = hits.reduce((m, h) => Math.max(m, h.score || 0), 0);
-    columns.push({ data: 'score', className: 'num scoreCol',
+    columns.push({ data: 'score', className: 'scoreCol',
         render: (d, type, row) => (type === 'display' ? blatScoreCell(row, maxScore) : d) });
-    columns.push({ data: 'identity', className: 'num identCol',
+    columns.push({ data: 'identity', className: 'identCol',
         render: (d, type, row) => (type === 'display' ? blatIdentityCell(row) : d) });
     columns.push({ data: 'strand', className: 'strandCol' });
-    columns.push({ data: 'span', className: 'num',
+    columns.push({ data: 'span', className: 'spanCol',
         render: (d, type, row) => (type === 'display' ? blatFmt(d) : d) });
 
     var dt = $('#blatTable').DataTable({
         data: hits,
         columns: columns,
         paging: false,
         info: false,
         order: [],
         language: { search: '', searchPlaceholder: 'Filter hits by locus, chrom, position…' }
     });
 
     $('#blatTable tbody').on('click', 'tr', function(ev) {
         if ($(ev.target).closest('a').length) { return; }   // let links work normally
         var d = dt.row(this).data();
         if (d) { blatSelect(dt, d.rank); }
@@ -610,37 +615,42 @@
         gbShowTimingDialog(hgBlatData.timing, clientRows);
     }
 
     blatApplyTooltips();
 }
 
 // ==== search form (the input page) ========================================
 // hgBlat.c emits  var hgBlatFormData = {...}  together with a real <form name="mainForm"> that
 // contains an empty <div id="blatFormBox"> and the C-generated genome search bar.  We build the
 // controls as real form fields *inside that form*, so the browser serializes them natively -
 // including the file input - and Submit / I'm feeling lucky / Clear stay plain submit buttons
 // handled by the existing C code.  There is no shadow form and no copying of values on submit.
 // Styling comes from hgBlat.css (loaded by webIncludeResourceFile in hgBlat.c), shared with the results page.
 
 // The Genome Browser's standard info icon, copied from printInfoIconSvg() in hg/lib/hui.c so the
-// form's icons are pixel-identical to the C-rendered ones elsewhere in the browser.
-var BLAT_INFO_SVG =
-    "<svg style='height:1.1em; vertical-align:top' viewBox='0 0 24 24' fill='none' " +
+// page's icons are pixel-identical to the C-rendered ones elsewhere in the browser.  The stroke
+// colour is a parameter because the results table wants a red one (see blatPositionCell); pass
+// 'currentColor' to let CSS drive it.
+function blatInfoSvg(stroke) {
+    return "<svg style='height:1.1em; vertical-align:top' viewBox='0 0 24 24' fill='none' " +
         "xmlns='http://www.w3.org/2000/svg'>" +
-    "<circle cx='12' cy='12' r='10' stroke='#1C274C' stroke-width='1.5'/>" +
-    "<path d='M12 17V11' stroke='#1C274C' stroke-width='1.5' stroke-linecap='round'/>" +
-    "<circle cx='1' cy='1' r='1' transform='matrix(1 0 0 -1 11 9)' fill='#1C274C'/></svg>";
+        "<circle cx='12' cy='12' r='10' stroke='" + stroke + "' stroke-width='1.5'/>" +
+        "<path d='M12 17V11' stroke='" + stroke + "' stroke-width='1.5' stroke-linecap='round'/>" +
+        "<circle cx='1' cy='1' r='1' transform='matrix(1 0 0 -1 11 9)' fill='" + stroke +
+        "'/></svg>";
+}
+var BLAT_INFO_SVG = blatInfoSvg('#1C274C');
 
 // The assembly-search syntax help.  setupGenomeSelector hides the info icon that
 // printGenomeSearchBar (hg/lib/web.c) normally puts next to the box, so the new form loses that
 // explanation of +word/-word/word*/"phrase"; we re-attach it to an icon after the label instead.
 // Kept word-for-word in sync with searchHelpText in web.c so both pickers explain the box the same
 // way.  This is HTML (a bullet list), rendered as such by the mouseover, so it is NOT htmlEncode'd -
 // like the C printInfoIcon, it relies on the string containing no double quotes to sit in a title=.
 var BLAT_GENOME_SEARCH_HELP =
     "All genome searches are case-insensitive.  Single-word searches default to prefix " +
     "matching if an exact match is not found. " +
     "<ul id='searchTipList' class='noBullets'>" +
     "<li> Force inclusion: Use a + sign before <b>+word</b> to ensure it appears in result.</li>" +
     "<li> Exclude words: Use a - sign before <b>-word</b> to exclude it from the search result.</li>" +
     "<li> Wildcard search: Add an * (asterisk) at end of <b>word*</b> to search for all terms starting with that prefix.</li>" +
     "<li> Phrase search: Enclose 'words in quotes' to search for the exact phrase.</li>" +
@@ -692,30 +702,103 @@
     return '<div id="blatLimitsBg" class="gbModalBg" style="display:none">' +
         '<div class="gbModal" role="dialog" aria-modal="true" aria-labelledby="blatLimitsTitle">' +
         '<div class="gbModalTitle" id="blatLimitsTitle">Input limits</div>' +
         row('DNA per sequence', '25,000 bases') +
         row('Protein / translated', '10,000 letters') +
         row('Sequences per run', '25') +
         row('Total per submission', '50,000 bases') +
         '<div class="gbModalText gbModalNote">Queries above these limits are rejected ' +
         'before alignment. For larger jobs, run BLAT from the ' +
         '<a target="_blank" href="https://hgdownload.soe.ucsc.edu/downloads.html#utilities_downloads">' +
         'command line</a> on your own server.</div>' +
         '<div class="gbModalBtns"><button type="button" class="gbPill" id="blatLimitsClose">Close</button></div>' +
         '</div></div>';
 }
 
+function blatFormBusyMarkup() {
+    // Spinner overlay shown between Submit and the arrival of the results page.  Built with the
+    // form (hidden) rather than on demand, so nothing has to be parsed or fetched at the moment the
+    // browser is already busy navigating away.
+    return '<div id="blatBusyBg" class="gbBusyBg" role="status" aria-live="polite">' +
+        '<div class="gbBusyCard"><div class="gbSpinner"></div>' +
+        '<div><div class="gbBusyTitle" id="blatBusyTitle">Running BLAT&hellip;</div>' +
+        '<div class="gbBusyText" id="blatBusyText"></div></div></div></div>';
+}
+
+function blatFormBusyWire(cfg) {
+    // The search is a plain form POST that navigates the page, so between the click on Submit and
+    // the arrival of the results nothing on screen changes at all - and a BLAT run is anywhere from
+    // under a second to well over half a minute, the long end being an assembly served by a dynamic
+    // BLAT server, which has to load its index from disk before it can answer.  Cover the form with
+    // a spinner for that interval.  Nothing here hides it again: the only thing that ends the wait
+    // is the results page replacing this one.  The exception is a Back navigation, where the browser
+    // may restore this page from its cache with the overlay still up - see the pageshow handler.
+    var form = document.mainForm;
+    if (!form) { return; }
+    var showTimer = null;
+    var slowTimer = null;
+
+    // Which button was used.  SubmitEvent.submitter is missing in older Safari, so also remember
+    // the last submit button the user activated and fall back to that.
+    var lastBtn = null;
+    $('#blatFormBox input[type=submit]').on('click', function() { lastBtn = this; });
+
+    $(form).on('submit', function(ev) {
+        var btn = (ev.originalEvent && ev.originalEvent.submitter) || lastBtn;
+        // Clear just empties the textarea and comes straight back; no spinner for that.
+        if (btn && btn.name === 'Clear') { return; }
+        // Nothing to align - hgBlat returns its "please paste a sequence" page immediately, so a
+        // spinner would only flash.
+        var ta = document.getElementById('blatUserSeq');
+        var file = document.getElementById('blatSeqFile');
+        if (!(ta && ta.value.trim()) && !(file && file.files && file.files.length)) { return; }
+
+        var allGenomes = $('#blat_allGenomes').prop('checked');
+        // The genome can be changed without reloading the page, so take the label from the search
+        // bar (which setupGenomeSearchBar keeps current) rather than from the page-load config.
+        var genomeInput = document.getElementById('genomeSearch');
+        var genome = (genomeInput && genomeInput.value) || cfg.dbLabel || 'the selected assembly';
+        document.getElementById('blatBusyTitle').innerHTML =
+            allGenomes ? 'Searching all genomes&hellip;' : 'Running BLAT&hellip;';
+        document.getElementById('blatBusyText').textContent = allGenomes ?
+            'Aligning your sequence against every assembly that has its own BLAT server.' :
+            'Aligning your sequence against ' + genome + '.';
+        // Held back a moment: a hg38 DNA search of this size comes back in well under a second, and
+        // a scrim that appears and vanishes again inside that time reads as a glitch rather than as
+        // progress.  Timers keep running on a page whose navigation is in flight, so the overlay
+        // still appears for every search slow enough to be worth reporting.
+        showTimer = setTimeout(function() { $('#blatBusyBg').addClass('on'); }, 350);
+
+        // A search that is still going after this long is almost always one where the assembly's
+        // BLAT index is being loaded on demand.  Say so, rather than leaving the user guessing
+        // whether anything is still happening.
+        slowTimer = setTimeout(function() {
+            document.getElementById('blatBusyText').textContent =
+                'Still working. Assemblies whose BLAT index is loaded on demand are slow to ' +
+                'answer the first search.';
+        }, 8000);
+    });
+
+    // Back button: browsers that restore this page from the back/forward cache restore it exactly as
+    // it was left, spinner and all.  Take it down and cancel the pending "still working" message.
+    $(window).on('pageshow', function() {
+        $('#blatBusyBg').removeClass('on');
+        if (showTimer) { clearTimeout(showTimer); showTimer = null; }
+        if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; }
+    });
+}
+
 function blatFormSetDb(db) {
     // Called by hgBlat.c's setupGenomeSearchBar onSelect.  Picking a genome does not reload the
     // page, so everything on it that depends on db is updated here instead: the hidden field that
     // the search is submitted with, and the sidebar links that carry a db= parameter.  The current
     // assembly label is updated by setupGenomeSearchBar itself.
     document.mainForm.db.value = db;
     $('#blatFormBox a[data-urltpl]').each(function() {
         this.href = this.getAttribute('data-urltpl').replace('$DB$', encodeURIComponent(db));
     });
 }
 
 function blatFormSidebar(cfg) {
     // Same links the classic page offered.  hgBlat.c supplies them as templates holding $DB$ (see
     // blatFormSetDb); the template is kept in data-urltpl so the link can be retargeted later.
     var tools = '';
@@ -862,60 +945,61 @@
                 '<div class="blatFileName" id="blatFileName"></div>' +
             '</div>' +
         '</div>' +
 
         '<div class="blatActions">' +
             '<input type="submit" class="gbPill primary" name="Submit" value="Submit" ' +
             `title="${htmlEncode('Align the sequence and show all matches')}">` +
             '<input type="submit" class="gbPill" name="Lucky" value="I&#39;m feeling lucky" ' +
             `title="${htmlEncode('Skip the list of matches and open the best-scoring one straight ' +
                 'away in the Genome Browser. Ignored when "Search many genomes" is ticked.')}">` +
             '<input type="submit" class="gbPill" name="Clear" value="Clear" ' +
             `title="${htmlEncode('Empty the query sequence box')}">` +
         '</div>' +
 
         '</div>' + blatFormSidebar(cfg) + '</div>' +
-        blatFormLimitsModal();
+        blatFormLimitsModal() +
+        blatFormBusyMarkup();
 
     // Move the C-generated genome search bar (real autocomplete over every assembly, already wired
     // by setupGenomeSearchBar) into its slot, rather than reimplementing it with a hardcoded list.
     var holder = document.getElementById('blatGenomeHolder');
     if (holder) { document.getElementById('blatGenomeSlot').appendChild(holder); }
 
 
     // Show the current assembly in the search bar itself instead of in a separate "Current genome:"
     // line - the bar is wide enough for the whole description.  setupGenomeSearchBar writes the new
     // one in on each pick, and focusing the bar selects all of it, so it reads as a filled-in search
     // box rather than as a value the user has to clear by hand.
     var genomeInput = document.getElementById('genomeSearch');
     if (genomeInput && cfg.dbLabel) { genomeInput.value = cfg.dbLabel; }
 
     // Restore the sequence from the cart without going through innerHTML (avoids re-escaping).
     document.getElementById('blatUserSeq').value = cfg.userSeq || '';
     blatFormCount();
 
     $('#blatUserSeq').on('input', blatFormCount);
     // Mirror the "Keep results" checkbox into its hidden field so an unticked box submits an
     // explicit 0 rather than nothing at all, and remember the choice in localStorage so it comes
     // back pre-set on the user's next visit (see keepResultsInit above).
     $('#blat_keepResults').on('change', function() {
         document.getElementById('blatKeepResultsVal').value = this.checked ? '1' : '0';
         blatSetKeepResultsPref(this.checked);
     });
     $('#blatTabPaste').on('click', function() { blatFormTab(false); });
     $('#blatTabUpload').on('click', function() { blatFormTab(true); });
-    // The example sequence is a real ~14 kb query, fetched on demand so it is not carried in every
+    // The example sequence is a real 2.5 kb query, fetched on demand so it is not carried in every
     // page load.  The link doubles as its own status indicator while the request is in flight.
     $('#blatExample').on('click', function(ev) {
         ev.preventDefault();
         var link = this;
         var label = cfg.exampleLabel;
         link.textContent = 'Loading example…';
         fetch(cfg.exampleUrl)
             .then(function(resp) {
                 if (!resp.ok) { throw new Error('HTTP ' + resp.status); }
                 return resp.text();
             })
             .then(function(fa) {
                 var ta = document.getElementById('blatUserSeq');
                 ta.value = fa.trim();
                 blatFormCount();
@@ -949,26 +1033,28 @@
             this.files && this.files.length ? this.files[0].name : '';
     });
     ['dragenter', 'dragover'].forEach(function(e) {
         drop.addEventListener(e, function(ev) { ev.preventDefault(); drop.classList.add('hot'); });
     });
     ['dragleave', 'drop'].forEach(function(e) {
         drop.addEventListener(e, function(ev) { ev.preventDefault(); drop.classList.remove('hot'); });
     });
     drop.addEventListener('drop', function(ev) {
         if (ev.dataTransfer.files.length) {
             fileInput.files = ev.dataTransfer.files;
             $(fileInput).trigger('change');
         }
     });
 
+    blatFormBusyWire(cfg);
+
     if (typeof convertTitleTagsToMouseovers === 'function') { convertTitleTagsToMouseovers(); }
 }
 
 $(document).ready(function() {
     if (typeof hgBlatData !== 'undefined' && document.getElementById('blatResults')) {
         blatBuild();
     }
     if (typeof hgBlatFormData !== 'undefined' && document.getElementById('blatFormBox')) {
         blatFormBuild();
     }
 });