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
@@ -1,974 +1,1060 @@
// 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, htmlEncode, commify, gbShowTimingDialog */
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 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 ?
` ⓘ` : '';
+ `title="${htmlEncode(hit.chromNote)} Click to learn more in the BLAT FAQ.">` +
+ `${blatInfoSvg('currentColor')}` : '';
// 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 `${htmlEncode(hit.chrom)}:` +
`${blatFmt(hit.tStart)}-${blatFmt(hit.tEnd)}` +
` ${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.
if (!hit.detailsUrl) { return ''; }
// htmlEncode the URL: detailsUrl embeds the user's query name, so an unescaped quote could break
// out of the href attribute (XSS).
return `Alignment`;
}
function blatLocusCell(hit) {
// Locus is plain text (not a link): the gene names are shown for context only. The cell grows with
// its content up to a max-width, then a very long locus (many overlapping genes) is clipped with a
// CSS ellipsis; the full string is always available on mouseover (title).
if (!hit.locusText) { return ''; }
return `
${htmlEncode(hit.locusText)}
`;
}
function blatScoreCell(hit, maxScore) {
// Score with a little bar chart after it, scaled to the highest score in this result set.
var pct = maxScore > 0 ? (hit.score / maxScore * 100) : 0;
return `${blatFmt(hit.score)}` +
``;
}
function blatIdentityCell(hit) {
// Just the percentage now (the bar chart moved to the Score column), kept in its semantic color.
var c = blatIdColor(hit.identity);
return `${hit.identity.toFixed(1)}%`;
}
function blatUnit() {
// A protein query is measured in amino acids, everything else in bases.
return hgBlatData.config.isProt ? 'aa' : 'bp';
}
function blatCoverageCell(hit) {
var left = (hit.qStart - 1) / hit.qSize * 100;
var width = (hit.qEnd - hit.qStart + 1) / hit.qSize * 100;
var u = blatUnit();
var tip = `Query matches the genome at ${blatFmt(hit.qStart)}-${blatFmt(hit.qEnd)}${u} out of ${blatFmt(hit.qSize)}${u}`;
return ``;
}
// ---- summary strip + detail panel ---------------------------------------
function blatSummaryStrip(cfg, queryCount) {
var stat = (k, v) => `
${k}` +
`${v}
`;
var div = '';
var assembly = stat('Assembly', htmlEncode(cfg.organism) + ' / ' + htmlEncode(cfg.db)) + div +
stat('Matches', 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', htmlEncode(cfg.queryName)) + div +
stat('Length', blatFmt(cfg.querySize) + ' ' + blatUnit()) + div + assembly;
}
var actions = '';
// "View all in browser" is the primary action, so it comes first.
if (cfg.viewAllUrl) {
actions += `View all in browser`;
}
// "Show Query Sequence" opens the query FASTA in a panel (with Download / Copy). Only on a fresh
// search, where the uploaded sequence is available (cfg.querySeqs emitted by hgBlat.c).
if (cfg.querySeqs && cfg.querySeqs.length) {
actions += '';
}
// "Share a link" creates a durable, minimal snapshot session (db + results bigPsl only) and shows
// its ?u=&s= reopen link (see blatShareLink). Only offered when a durable bigPsl backs the
// results (cfg.canShare = autoBigPsl); without it there is nothing for the shared link to reopen.
if (cfg.canShare) {
// A small share-nodes icon precedes the label so users learn to associate it with sharing.
var shareIcon = '';
actions += '';
}
// "Rename BLAT Track" opens a modal to rename the results custom track. This is a JS-native
// button (renders immediately with the strip) that replaces the old C-emitted inline form, which
// only appeared after the buildBigPsl AJAX finished and reflowed the page when clicked.
if (cfg.canRename) {
actions += '';
}
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') +
blatTileSkeleton('Mismatch', 'dvMismatch') +
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 === '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);
// Location line is plain text, so set it via textContent (blatSet 'text') - no HTML, nothing to
// escape. q and locus stay raw here for that reason.
var locus = hit.locusText ? hit.locusText + ' · ' : '';
var q = hgBlatData.config.multiQuery ? hit.qName + ' · ' : '';
blatSet('dvLoc', 'text',
`#${hit.rank} · ${q}${locus}${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',
'Open in Genome Browser': 'Genomic location of the match (1-based). Click the position to ' +
'open the Genome Browser there, or the icon to open it in a new tab.',
'Show': 'Show the base-by-base alignment of your sequence to the genome',
'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 --------------------------------------------------------
// The snapshot link we created for this page view, cached so re-opening the box doesn't make another.
var blatShareCachedUrl = null;
// Render the share box. url set -> show the link + Copy; url null -> "Creating link…"; msg (url null)
// -> show an error.
function blatShowShareBox(box, url, msg) {
box.style.display = 'flex';
if (msg) {
box.innerHTML = '' +
htmlEncode(msg) + '';
return;
}
if (!url) {
box.innerHTML = 'Creating link…';
return;
}
box.innerHTML =
'Shareable link — anyone with it can reopen these ' +
'BLAT results. It stores only the results (not your other tracks or settings) and stays ' +
'active as long as it is used.' +
'' +
'';
var inp = document.getElementById('gbShareInput');
inp.value = url;
inp.focus();
inp.select();
$('#blatShareCopy').on('click', function() {
inp.select();
if (navigator.clipboard) { navigator.clipboard.writeText(url); }
else { document.execCommand('copy'); }
this.textContent = 'Copied';
});
}
function blatShareLink() {
// Create (or reveal) a durable share link. It is backed by a lightweight "snapshot" session that
// stores only db + the results bigPsl - not the whole cart - under a server-generated unique name
// (see lib/snapshotSession.c). hgBlat's ?u=&s= reopen (doShareReopen) rebuilds the results table
// from that bigPsl. The token generation, uniqueness and reaping are shared with hgc and the
// top-right "Share a link".
var box = document.getElementById('gbShareBox');
if (!box) { return; }
if (box.style.display === 'flex') { box.style.display = 'none'; return; } // toggle off
// Already viewing a shared session link: the current URL is itself the shareable link.
if (/[?&]s=/.test(window.location.search)) { blatShowShareBox(box, window.location.href); return; }
// Already created one this page view: reuse it rather than creating another session.
if (blatShareCachedUrl) { blatShowShareBox(box, blatShareCachedUrl); return; }
var cfg = hgBlatData.config;
blatShowShareBox(box, null); // "Creating link…"
var body = 'hgsid=' + encodeURIComponent(cfg.hgsid || '') +
'&hgS_doSaveSessionJson=1&hgS_shareAnon=1&hgS_snapshotType=blat';
fetch('../cgi-bin/hgSession', {method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data || !data.name) {
blatShowShareBox(box, null, (data && data.error) || 'Could not create the link.');
return;
}
blatShareCachedUrl = window.location.origin + '/cgi-bin/hgBlat?u=l&s=' +
encodeURIComponent(data.name);
blatShowShareBox(box, blatShareCachedUrl);
})
.catch(function() {
blatShowShareBox(box, null, 'Could not reach the server. Please try again.');
});
}
// ---- Rename BLAT track (modal) -------------------------------------------
// The results custom track is built (and renamed) by hgBlat.c's inline code, which exposes a small
// window.blatRenameCt(name, description) helper (it POSTs to hgc's buildBigPsl and rebuilds the
// track). We reuse that helper (no new endpoint), just swapping its old inline toggle-form UI for a
// proper modal dialog. The current name/description come from cfg (hgBlat.c), not a global, so this
// does not depend on any generic page-global.
function blatRenameModalHtml(cfg) {
// hgSession link is relative (same /cgi-bin/), carrying db + hgsid so the session page opens in
// this assembly and cart.
var sessionUrl = `hgSession?db=${encodeURIComponent(cfg.db)}&hgsid=${encodeURIComponent(cfg.hgsid)}`;
return '
' +
'
' +
'
Rename BLAT Track
' +
'
Every BLAT result is stored in its own track in the Genome ' +
'Browser. You can rename the track here. Results will disappear after 2–3 days, unless ' +
`they are saved into a Session link.
` +
'' +
'' +
'' +
'' +
'
' +
'' +
'' +
'
';
}
function blatCloseRename() {
var bg = document.getElementById('gbModalBg');
if (bg) { bg.style.display = 'none'; }
}
function blatOpenRename() {
var bg = document.getElementById('gbModalBg');
if (!bg) { return; }
// Pre-fill with the track's current name/description (emitted by hgBlat.c in cfg).
var cfg = hgBlatData.config;
document.getElementById('blatRenameName').value = cfg.trackName || '';
document.getElementById('blatRenameDesc').value = cfg.trackDescription || '';
bg.style.display = 'flex';
document.getElementById('blatRenameName').focus();
document.getElementById('blatRenameName').select();
}
function blatWireRename() {
$('#blatRenameBtn').on('click', blatOpenRename);
$('#blatRenameCancel').on('click', blatCloseRename);
// Click on the dark backdrop (but not the dialog itself) closes.
$('#gbModalBg').on('click', function(ev) {
if (ev.target === this) { blatCloseRename(); }
});
$(document).on('keydown.blatRename', function(ev) {
var bg = document.getElementById('gbModalBg');
if (bg && bg.style.display !== 'none' && ev.key === 'Escape') { blatCloseRename(); }
});
$('#blatRenameOk').on('click', function() {
var name = document.getElementById('blatRenameName').value.trim();
var desc = document.getElementById('blatRenameDesc').value.trim();
if (!name) { document.getElementById('blatRenameName').focus(); return; }
// Reuse hgBlat.c's window.blatRenameCt(name, description): rebuilds the custom track under the
// new name via the existing hgc buildBigPsl call. Keep cfg in sync so a re-open of the modal
// shows the new values.
if (typeof window.blatRenameCt === 'function') {
hgBlatData.config.trackName = name;
hgBlatData.config.trackDescription = desc;
window.blatRenameCt(name, desc);
}
blatCloseRename();
});
}
// ---- FASTA viewer (generic) ----------------------------------------------
function blatToFasta(seqs) {
// seqs: [{name, seq}, ...] -> FASTA text, sequence wrapped at 60 chars per line.
return seqs.map(function(s) {
var body = String(s.seq || '').toUpperCase().replace(/(.{60})/g, '$1\n').replace(/\n$/, '');
return '>' + s.name + '\n' + body;
}).join('\n');
}
function blatShowFasta(box, seqs, fileName) {
// Render seqs as FASTA inside `box`, with Copy-to-clipboard and Download buttons. Generic — takes
// any [{name, seq}] list so it can be reused for other sequences later.
var fasta = blatToFasta(seqs);
box.style.display = 'flex';
box.innerHTML =
'
' +
'Query sequence (FASTA):' +
'' +
'' +
'' +
'
';
var ta = document.getElementById('blatSeqText');
ta.value = fasta;
document.getElementById('blatSeqCopy').addEventListener('click', function() {
ta.select();
if (navigator.clipboard) { navigator.clipboard.writeText(fasta); }
else { document.execCommand('copy'); }
this.textContent = 'Copied';
});
document.getElementById('blatSeqDownload').addEventListener('click', function() {
var a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([fasta], { type: 'text/plain' }));
a.download = fileName || 'query.fa';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function() { URL.revokeObjectURL(a.href); }, 0);
});
document.getElementById('blatSeqClose').addEventListener('click', function() {
box.style.display = 'none';
});
}
function blatShowQuerySeq() {
var box = document.getElementById('blatSeqBox');
if (box.style.display === 'flex') { box.style.display = 'none'; return; } // toggle off
blatShowFasta(box, hgBlatData.config.querySeqs, 'blatQuery.fa');
}
// ---- build ---------------------------------------------------------------
function blatBuild() {
var cfg = hgBlatData.config;
var hits = hgBlatData.hits;
// When loaded with &measureTiming=1 the C side attaches hgBlatData.timing; time the client
// render too so the dialog shows the full server+client picture.
var tBuildStart = (hgBlatData.timing && window.performance) ? performance.now() : 0;
// Pin a stable, shareable URL into the address bar (no server redirect) so refresh, bookmark and
// "Share a link" all use the trash-backed reopen link instead of the transient POST/search URL.
if (cfg.shareUrl) {
try { history.replaceState(null, '', cfg.shareUrl); } catch (e) { /* older browsers: ignore */ }
}
var back = cfg.backUrl ?
`Back to Genome Browser` : '';
// The page actions live in the gold main-header bar (framework #sectTtl), next to the title -
// so there is no separate toolbar (.blatHead is gone). Injected into #sectTtl below.
var headActions =
`${back}New BLAT search`;
// 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 the original page anytime.` : '';
var bannerHtml =
`
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 ` +
`genome@soe.ucsc.edu.
`;
var queryCount = new Set(hits.map(h => h.qName)).size;
var th = [];
- th.push('
#
');
+ th.push('
#
');
if (cfg.multiQuery) { th.push('
Query
'); }
th.push('
Open in Genome Browser
');
th.push('
Show
');
th.push('
Query coverage
');
if (cfg.hasLocus) { th.push('
Locus
'); }
- th.push('
Score
');
- th.push('
Identity
');
+ th.push('
Score
');
+ th.push('
Identity
');
th.push('
Strand
');
- th.push('
Span
');
+ th.push('
Span
');
// detail dock sits above the table: with long hit lists a bottom dock scrolls out of view
document.getElementById('blatResults').innerHTML =
bannerHtml +
`
` +
(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); }
});
// 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); }
});
// 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 =
`
Click a hit below to see its alignment details. ` +
`If you are missing matches that you think should be there, ` +
`read our BLAT FAQ or ` +
`contact us.
`;
// Timing report (only when loaded with &measureTiming=1): a pill in the summary strip that opens
// the shared dialog with the server phases plus the client render time.
if (hgBlatData.timing) {
var clientRows = [{ label: 'build page (JS)',
ms: Math.round(performance.now() - tBuildStart) }];
var pill = document.createElement('button');
pill.type = 'button';
pill.className = 'gbPill';
pill.id = 'blatTimingBtn';
pill.innerHTML = '⏱ Timing';
pill.title = 'Show where this page spent its time (server and browser)';
pill.addEventListener('click', function() {
gbShowTimingDialog(hgBlatData.timing, clientRows);
});
var strip = document.querySelector('#blatResults .gbStripActions') ||
document.querySelector('#blatResults .gbStrip');
if (strip) { strip.appendChild(pill); }
// measureTiming=1 on the URL is an explicit request to see the numbers, so open the dialog
// right away; the pill stays for reopening it after Close.
gbShowTimingDialog(hgBlatData.timing, clientRows);
}
blatApplyTooltips();
}
// ==== search form (the input page) ========================================
// hgBlat.c emits var hgBlatFormData = {...} together with a real