c7fcdde6db52ba01fbabfa695b49205bf7261a33
max
Tue Aug 4 07:40:32 2026 -0700
hgBlat/hgc: QA fixes for the new BLAT table view and alignment page
Address Gerardo's QA findings on the new BLAT UI:
- Table view: label each hit with its own query (new Query column +
"Queries" count in the summary) when the search has multiple queries,
reading cfg.multiQuery / hit.qName from the payload.
- Table view: strip the "hub_NNN_" prefix from the Assembly field so hub
assemblies no longer show a doubled prefix.
- Alignment page: strip the same prefix from the organism in the page title
for non-GenArk assembly hubs (blatAsmLabel fallback path).
- Table view: do not pre-select hit #1; show a prompt until the user clicks
a row, since top hits are often tied.
- Table view: add a divider between the Browser / New tab / Alignment links.
- Alignment page: sidebar link now reads "Side by Side Alignment" to match
the section heading.
- Alignment page: comma-format coordinates and base counts in the summary,
matching the table view and the rest of the browser.
refs #37893
diff --git src/hg/js/hgBlat.js src/hg/js/hgBlat.js
index 19eebb0ed1a..6a0d49d2320 100644
--- src/hg/js/hgBlat.js
+++ src/hg/js/hgBlat.js
@@ -1,455 +1,474 @@
// hgBlat.js - client-side rendering of the hgBlat "Table" output mode.
//
// hgBlat.c emits an inline object var hgBlatData = { config, hits } and an empty
//
. This script builds the whole results UI from that data:
// - a card with a summary strip (query / length / assembly / hit count + actions)
// - a sortable, filterable DataTable whose cells are rendered here (identity bar,
// query-coverage bar, linked loci, action links, comma-formatted position)
// - a docked "selected hit" detail panel updated on row click
// Header tooltips reuse the Genome Browser's own mechanism (title + convertTitleTagsToMouseovers).
/* jshint esnext: true */
/* global $, hgBlatData, convertTitleTagsToMouseovers */
var blatSelectedRank = null; // rank of the row shown in the detail panel
function blatFmt(n) {
// 12345 -> "12,345"
return Number(n).toLocaleString('en-US');
}
function blatEsc(s) {
// HTML-escape a value for safe insertion as text
return $('
`;
}
function blatIdentityCell(hit) {
var c = blatIdColor(hit.identity);
return `` +
`` +
`${hit.identity.toFixed(1)}%`;
}
function blatCoverageCell(hit) {
var left = (hit.qStart - 1) / hit.qSize * 100;
var width = (hit.qEnd - hit.qStart + 1) / hit.qSize * 100;
var tip = `query ${blatFmt(hit.qStart)}–${blatFmt(hit.qEnd)} of ${blatFmt(hit.qSize)} bp`;
return ``;
}
// ---- summary strip + detail panel ---------------------------------------
-function blatSummaryStrip(cfg) {
+function blatSummaryStrip(cfg, queryCount) {
var stat = (k, v) => `
${k}` +
`${v}
`;
var div = '';
- var stats = stat('Query', blatEsc(cfg.queryName)) + div +
- stat('Length', blatFmt(cfg.querySize) + ' bp') + div +
- stat('Assembly', blatEsc(cfg.organism) + ' / ' + blatEsc(cfg.db)) + div +
+ var assembly = stat('Assembly', blatEsc(cfg.organism) + ' / ' + blatEsc(cfg.db)) + div +
stat('Hits', blatFmt(cfg.hitCount));
+ var stats;
+ if (cfg.multiQuery) {
+ // With more than one query sequence a single query name/length would be wrong, so show the
+ // number of distinct queries; each hit's own query is in the table's Query column.
+ stats = stat('Queries', blatFmt(queryCount)) + div + assembly;
+ } else {
+ stats = stat('Query', blatEsc(cfg.queryName)) + div +
+ stat('Length', blatFmt(cfg.querySize) + ' bp') + div + assembly;
+ }
var actions = '';
// "Share a link" saves a durable anonymous session that reopens these results; it only works
// when a stable custom track was made from them (cfg.canShare, i.e. autoBigPsl is on).
if (cfg.canShare) {
actions += '';
}
if (cfg.viewAllUrl) {
actions += `View all in browser`;
}
// custom-track Rename/Delete buttons (emitted by hgBlat.c) get relocated here after render
return `
${stats}${actions}
`;
}
var BLAT_TILE_TIPS = {
'Score': 'BLAT score: matches minus mismatches and gap penalties. Higher is better.',
'Identity': 'Percent identity of the aligned bases.',
'Matches': 'Query bases that match the genome.',
'Mismatch': 'Bases that differ between query and genome.',
'Gaps': 'Number of gaps (insertions or deletions) in the alignment.',
'Blocks': 'Number of ungapped aligned blocks.',
'Strand': 'Genome strand the query matched (+ or -).',
'Q span': 'Range of the query sequence that aligned (1-based).'
};
function blatTileSkeleton(label, id, color) {
var style = color ? ` style="color:${color}"` : '';
var tip = BLAT_TILE_TIPS[label] || '';
return `
${label}
` +
`
`;
}
function blatDetailSkeleton() {
// Built once; blatRenderDetail() only updates values, so the tile-label tooltips
// are wired a single time by convertTitleTagsToMouseovers.
var tiles =
blatTileSkeleton('Score', 'dvScore') +
blatTileSkeleton('Identity', 'dvIdentity') +
blatTileSkeleton('Matches', 'dvMatches', '#15803d') +
blatTileSkeleton('Mismatch', 'dvMismatch', '#b45309') +
blatTileSkeleton('Gaps', 'dvGaps') +
blatTileSkeleton('Blocks', 'dvBlocks') +
blatTileSkeleton('Strand', 'dvStrand') +
blatTileSkeleton('Q span', 'dvQspan');
document.getElementById('blatDetail').innerHTML =
`
`;
if (typeof convertTitleTagsToMouseovers === 'function') { convertTitleTagsToMouseovers(); }
}
function blatSet(id, prop, val) {
var e = document.getElementById(id);
if (!e) { return; }
if (prop === 'text') { e.textContent = val; }
else if (prop === 'html') { e.innerHTML = val; }
else if (prop === 'href') { e.setAttribute('href', val); }
else if (prop === 'color') { e.style.color = val; }
}
function blatRenderDetail(hit) {
if (!hit || !document.getElementById('blatDetail')) { return; }
if (!document.getElementById('dvScore')) { blatDetailSkeleton(); }
var idc = blatIdColor(hit.identity);
var locus = hit.locusText ? blatEsc(hit.locusText) + ' · ' : '';
+ var q = hgBlatData.config.multiQuery ? blatEsc(hit.qName) + ' · ' : '';
blatSet('dvLoc', 'html',
- `#${hit.rank} · ${locus}${blatEsc(hit.chrom)}:${blatFmt(hit.tStart)}-${blatFmt(hit.tEnd)}`);
+ `#${hit.rank} · ${q}${locus}${blatEsc(hit.chrom)}:${blatFmt(hit.tStart)}-${blatFmt(hit.tEnd)}`);
blatSet('dvScore', 'text', blatFmt(hit.score));
blatSet('dvIdentity', 'text', hit.identity.toFixed(1) + '%');
blatSet('dvIdentity', 'color', idc);
blatSet('dvMatches', 'text', blatFmt(hit.matches));
blatSet('dvMismatch', 'text', blatFmt(hit.misMatch));
blatSet('dvGaps', 'text', blatFmt(hit.gaps));
blatSet('dvBlocks', 'text', blatFmt(hit.blocks));
blatSet('dvStrand', 'text', hit.strand);
blatSet('dvQspan', 'text', blatFmt(hit.qStart) + '–' + blatFmt(hit.qEnd));
blatSet('dvBrowser', 'href', hit.browserUrl);
blatSet('dvNewTab', 'href', hit.newTabUrl);
// Show the Alignment box whenever a base-by-base alignment page is available (htcUserAli on a
// fresh search, htcBlatAlign on a shared-link reopen); hide it only if detailsUrl is missing.
var alignBox = document.getElementById('dvAlignBox');
if (alignBox) { alignBox.style.display = hit.detailsUrl ? '' : 'none'; }
if (hit.detailsUrl) {
blatSet('dvViewAlign', 'href', hit.detailsUrl);
blatSet('dvAlign', 'text',
'See the base-by-base alignment of your query against ' + hit.chrom +
': matches, mismatches and gaps across the whole span.');
}
}
function blatSelect(dt, rank) {
blatSelectedRank = rank;
$('#blatTable tbody tr').each(function() {
var d = dt.row(this).data();
$(this).toggleClass('blatSel', !!d && d.rank === rank);
});
var hit = hgBlatData.hits.find(h => h.rank === rank);
blatRenderDetail(hit);
}
// ---- header tooltips (reuse the browser's title -> mouseover system) -----
var BLAT_HEADER_TIPS = {
'#': 'Rank by the chosen sort order',
+ 'Query': 'The query sequence this hit came from',
'Position': 'Genomic location of the match (1-based). Click to open the Genome Browser.',
'Actions': 'Open the match in the browser, see the alignment details, or open in a new tab',
'Locus': 'Nearest gene(s), and whether the hit falls in an exon, intron, or intergenic region',
'Score': 'BLAT score: matches minus mismatches and gap penalties. Higher is better.',
'Identity': 'Percent identity of the aligned bases',
'Strand': 'Genome strand the query matched (+ or -)',
'Query coverage': 'Which part of the query aligned (blue) across its full length',
'Span': 'Length of the match on the genome (bp). Larger than the query length means ' +
'the alignment crosses introns or deletions.'
};
function blatApplyTooltips() {
$('#blatTable thead th').each(function() {
var tip = BLAT_HEADER_TIPS[$(this).text().trim()];
if (tip) { $(this).attr('title', tip); }
});
if (typeof convertTitleTagsToMouseovers === 'function') {
convertTitleTagsToMouseovers();
}
}
// ---- share a link --------------------------------------------------------
function blatShareLink() {
// Save the current cart as an anonymous shared session (hgSession API), then build a link back
// to this page that restores it. The restored cart carries the durable bigPsl custom track
// made from these results, which hgBlat rebuilds this table from (no BLAT re-run) on open.
var cfg = hgBlatData.config;
var box = document.getElementById('blatShareBox');
box.style.display = 'flex';
box.innerHTML = 'Creating shareable link…';
// Save the cart as an anonymous shared session; same endpoint/params as topLinks.js "Share a link".
$.ajax({
type: 'POST',
url: '../cgi-bin/hgSession',
data: { hgsid: cfg.hgsid, 'hgS_doSaveSessionJson': 1, 'hgS_shareAnon': 1 },
dataType: 'json',
success: function(data) {
if (!data || !data.name) {
box.innerHTML = 'Could not create link: ' +
blatEsc(data && data.error ? data.error : 'unknown error') + '';
return;
}
// anonymous sessions are saved under the reserved user "l"; hgBlat maps the short
// u=/s= params to the session load and rebuilds this table from the durable bigPsl
// custom track (no BLAT re-run)
var link = window.location.origin + window.location.pathname +
'?u=l&s=' + encodeURIComponent(data.name);
box.innerHTML =
'Shareable link (opens these results for anyone):' +
'' +
'';
var inp = document.getElementById('blatShareInput');
inp.value = link;
inp.focus();
inp.select();
$('#blatShareCopy').on('click', function() {
inp.select();
if (navigator.clipboard) { navigator.clipboard.writeText(link); }
else { document.execCommand('copy'); }
document.getElementById('blatShareCopy').textContent = 'Copied';
});
},
error: function() {
box.innerHTML = 'Could not reach the server. Please try again.';
}
});
}
// ---- build ---------------------------------------------------------------
function blatBuild() {
var cfg = hgBlatData.config;
var hits = hgBlatData.hits;
blatInjectStyle();
var back = cfg.backUrl ?
`Back to browser` : '';
// "Old BLAT result page" re-renders the classic list from this session's fresh trash files and
// clears the blatNewPage preference (blatNewPage=0), so future searches use the classic page
// until the user opts back in. Only offered on a fresh search (cfg.canOldPage), not a reopen.
var oldPage = cfg.canOldPage ?
`Old BLAT result page` : '';
var headHtml =
`