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 @@ -72,31 +72,32 @@ .blatShareMsg { font-size:14px; color:var(--muted); } .blatShareInput { flex:1; min-width:260px; font-size:13px; padding:5px 8px; border:1px solid var(--btnLine); border-radius:3px; background:#fff; } #blatTable { font-size:13px; width:100%; border-collapse:collapse; } #blatTable thead th { background:var(--headrow); border-bottom:1px solid var(--headrowLine); font-size:12px; color:var(--navy); font-weight:700; padding:8px 12px; white-space:nowrap; } #blatTable tbody td { padding:8px 12px; border-bottom:1px solid var(--lineSoft); white-space:nowrap; vertical-align:middle; } #blatTable tbody tr { cursor:pointer; } #blatTable tbody tr:nth-child(even) { background:var(--stripe); } #blatTable tbody tr:hover { background:var(--hover); } #blatTable tbody tr.blatSel { background:var(--sel); box-shadow:inset 3px 0 0 var(--navy); } #blatTable td.num, #blatTable th.num { text-align:right; font-variant-numeric:tabular-nums; } #blatTable td.rankCol { color:var(--faint); } #blatTable td.strandCol { text-align:center; color:var(--muted); } - #blatTable td.actionsCol a { color:var(--accent); font-weight:700; margin-right:12px; } + #blatTable td.actionsCol a { color:var(--accent); font-weight:700; } + #blatTable td.actionsCol .blatActSep { color:var(--line); margin:0 10px; } #blatTable a { color:var(--accent); text-decoration:none; } #blatTable a:hover { color:var(--accentHover); text-decoration:underline; } .blatRowHint { padding:11px 18px 2px; font-size:13px; color:var(--muted); } .blatLocus { max-width:230px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .chrNote { color:var(--faint); cursor:help; margin-left:4px; } .blatIdWrap { display:flex; align-items:center; gap:9px; justify-content:flex-end; } .blatIdBar { flex:0 0 54px; height:8px; background:#dce4ee; border:1px solid var(--headrowLine); overflow:hidden; } .blatIdBar > i { display:block; height:100%; } .blatIdPct { font-size:13px; font-weight:700; width:48px; text-align:right; font-variant-numeric:tabular-nums; } .blatCov { position:relative; display:block; width:150px; height:10px; background:#dce4ee; border:1px solid var(--headrowLine); } .blatCov > i { position:absolute; top:0; bottom:0; background:var(--accent); } #blatTable_filter { float:left; margin:0 0 10px; } #blatTable_filter input { width:300px; max-width:55vw; border:1px solid var(--btnLine); border-radius:3px; padding:5px 9px; font-size:14px; } #blatTable_wrapper { padding:6px 18px 4px; } @@ -117,33 +118,35 @@ document.head.appendChild(st); } // ---- cell renderers ------------------------------------------------------ function blatPositionCell(hit) { var note = hit.chromNote ? ` ` : ''; return `${blatEsc(hit.chrom)}:` + `${blatFmt(hit.tStart)}-${blatFmt(hit.tEnd)}${note}`; } function blatActionsCell(hit) { // detailsUrl is the base-by-base alignment page (htcUserAli on a fresh search, htcBlatAlign on a // shared-link reopen); it is always present now, but guard in case a future caller omits it. - var align = hit.detailsUrl ? `Alignment` : ''; - return `Browser` + - `New tab` + align; + var parts = [`Browser`, + `New tab`]; + if (hit.detailsUrl) { parts.push(`Alignment`); } + // a thin divider between the links makes the grouping clearer + return parts.join('|'); } function blatLocusCell(hit, cfg) { if (!hit.locusText) { return ''; } var inner; if (hit.locusType && hit.locusGenes && hit.locusGenes.length) { var links = hit.locusGenes.map(function(g) { return `${blatEsc(g)}`; }).join('-'); inner = `${blatEsc(hit.locusType)} ${links}`; } else { inner = blatEsc(hit.locusText); } return `
${inner}
`; } @@ -153,38 +156,45 @@ 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.', @@ -236,32 +246,33 @@ 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'; } @@ -275,30 +286,31 @@ 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); } @@ -364,59 +376,63 @@ 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 = `
BLAT results` + `${oldPage}${back}` + `New BLAT search
`; + var queryCount = new Set(hits.map(h => h.qName)).size; + var th = []; th.push('#'); + if (cfg.multiQuery) { th.push('Query'); } th.push('Position'); th.push('Actions'); if (cfg.hasLocus) { th.push('Locus'); } th.push('Score'); th.push('Identity'); th.push('Strand'); th.push('Query coverage'); th.push('Span'); // detail dock sits above the table: with long hit lists a bottom dock scrolls out of view document.getElementById('blatResults').innerHTML = - `
${headHtml}${blatSummaryStrip(cfg)}` + + `
${headHtml}${blatSummaryStrip(cfg, queryCount)}` + `` + `
` + `
Click any row to inspect it in the panel above.
` + `${th.join('')}
`; // relocate the C-emitted custom-track buttons into the summary strip var actionBar = document.querySelector('.blatStripActions'); ['renameFormItem', 'deleteCtForm'].forEach(function(id) { var el = document.getElementById(id); if (el && actionBar) { actionBar.appendChild(el); } }); $('#blatShareBtn').on('click', blatShareLink); var columns = []; columns.push({ data: 'rank', className: 'num 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) : '') }); if (cfg.hasLocus) { columns.push({ data: 'locusText', render: (d, type, row) => (type === 'display' ? blatLocusCell(row, cfg) : (d || '')) }); } columns.push({ data: 'score', className: 'num' }); columns.push({ data: 'identity', className: 'num', render: (d, type, row) => (type === 'display' ? blatIdentityCell(row) : d) }); columns.push({ data: 'strand', className: 'strandCol' }); columns.push({ data: null, className: 'covCol', orderable: false, render: (d, type, row) => (type === 'display' ? blatCoverageCell(row) : (row.qEnd - row.qStart + 1)) }); @@ -432,24 +448,27 @@ }); $('#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); } }); // Keep the selected-row highlight after sort/filter. Header tooltips are wired once below (the // persists across draws); we deliberately do NOT re-run convertTitleTagsToMouseovers on // every draw, as it re-scans the whole document and adds global listeners on each call. dt.on('draw', function() { if (blatSelectedRank !== null) { blatSelect(dt, blatSelectedRank); } }); - if (hits.length) { blatSelect(dt, hits[0].rank); } + // No hit is pre-selected: several hits are often tied on score/identity, so picking one for the + // user is misleading. The detail panel shows a prompt until a row is clicked. + document.getElementById('blatDetail').innerHTML = + `
Select a hit below to see its alignment details.
`; blatApplyTooltips(); } $(document).ready(function() { if (typeof hgBlatData !== 'undefined' && document.getElementById('blatResults')) { blatBuild(); } });