c683ecb63d721deb02fa8ab15bf66f70f1c3a326
max
Sat Jul 25 18:25:00 2026 -0700
hgBlat/hgc: single-page BLAT results view with shareable alignment links
Add a modern single-page BLAT results table (hgBlat.js) and a non-frameset
alignment view (showSomeAlignmentModern in hgc, gated by the blatNewPage cart
var). Share/reopen a result set from a durable bigPsl custom track pinned in the
cart via a saved session (htcBlatAlign / loadBlatShareSessionIfAny). Factor the
shared helpers into a new blatShare module (lib/blatShare.c, inc/blatShare.h).
diff --git src/hg/js/hgBlat.js src/hg/js/hgBlat.js
new file mode 100644
index 00000000000..19eebb0ed1a
--- /dev/null
+++ src/hg/js/hgBlat.js
@@ -0,0 +1,455 @@
+// 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) {
+ 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 +
+ stat('Hits', blatFmt(cfg.hitCount));
+ 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) + ' · ' : '';
+ blatSet('dvLoc', 'html',
+ `#${hit.rank} · ${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',
+ '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 =
+ `
');
+
+ // detail dock sits above the table: with long hit lists a bottom dock scrolls out of view
+ document.getElementById('blatResults').innerHTML =
+ `