cc23b76a1f2c7dc6a29c227661bfb0ce4d20eb54
lrnassar
Fri Sep 4 13:43:48 2026 -0700
BLAT page polish from QA: Load example button, corrected input limits, wording fixes. refs #37996
Load example is a chip-style button with a mouseover instead of a hyperlink. Removed the
Show input limits modal; corrected the limits in hgTracksHelp.html, stale since 2013 (DNA is
75,000 bases per sequence and 187,500 combined, protein and translated 10,000 and 25,000),
added a #blatLimits anchor and pointed the two over-limit BLAT warnings and the form at it.
Genome selector: section title Target assembly, type-to-search hint in the dropdown,
Shortcuts: label, input and Query type select sized to match. Similar tools sidebar uses
house-style hyphens and corrects the findMotif name and description. Share and rename text
now states link lifetimes accurately (snapshot links last years, only sessions are
permanent). Alignment page buttons match .gbPill, summary strip spacing fixed, New BLAT
search button demoted from primary. Documents blatResultsGroup in ex.hg.conf.
diff --git src/hg/js/utils.js src/hg/js/utils.js
index 5234d6df826..5f27b64a1fd 100644
--- src/hg/js/utils.js
+++ src/hg/js/utils.js
@@ -1,5584 +1,5589 @@
// Utility JavaScript
// "use strict";
// Don't complain about line break before '||' etc:
/* jshint -W014 */
/* jshint -W087 */
/* jshint esnext: true */
var debug = false;
/* Support these formats for range specifiers. Note the ()'s around chrom,
* start and end portions for substring retrieval: */
// \s = whitespace, \w = azAZ0-9_,
var canonicalRangeExp = /^[\s]*([\w._#-]+)[\s]*:[\s]*([-0-9,]+)[\s]*[-_][\s]*([0-9,]+)[\s]*$/;
var gbrowserRangeExp = /^[\s]*([\w._#-]+)[\s]*:[\s]*([0-9,]+)[\s]*\.\.[\s]*([0-9,]+)[\s]*$/;
var lengthRangeExp = /^[\s]*([\w._#-]+)[\s]*:[\s]*([0-9,]+)[\s]*\+[\s]*([0-9,]+)[\s]*$/;
var bedRangeExp = /^[\s]*([\w._#-]+)[\s]+([0-9,]+)[\s]+([0-9,]+)[\s]*$/;
var sqlRangeExp = /^[\s]*([\w._#-]+)[\s]*\|[\s]*([0-9,]+)[\s]*\|[\s]*([0-9,]+)[\s]*$/;
var singleBaseExp = /^[\s]*([\w._#-]+)[\s]*:[\s]*([0-9,]+)[\s]*$/;
// also allow gnomad variants, ex: 12-1234-A-C
var gnomadVarExp = "^(([0-9]+)|(X|Y|M|MT))-([0-9]+)-([A-Za-z]+)-([A-Za-z]+)$";
// allow gnomad ranges, ex: 12-1234-11223344
var gnomadRangeExp = "^(([0-9]+)|(X|Y|M|MT))-([0-9]+)-([0-9]+)$";
// gene symbol + exon number: "TP53 exon 5", "BRCA1 exon 10"
var geneExonExp = /^[\s]*([A-Za-z][A-Za-z0-9._-]*)[\s]+exon[\s]+([0-9]+)[\s]*$/i;
// compact exon notation: "TP53:e.5", "NM_000546:e.5+2", "BRCA2:e.10-3"
var geneExonCoordExp = /^[\s]*([A-Za-z0-9._-]+):e\.([0-9]+)([+-][0-9]+)?[\s]*$/i;
function createInfoIcon(text) {
/* Create an info icon (i in circle) with tooltip text.
* Returns a span element containing the SVG icon.
* Uses addMouseover() for consistent tooltip behavior. */
var span = document.createElement("span");
span.style.marginLeft = "4px";
span.innerHTML = "";
addMouseover(span, text);
return span;
}
function copyToClipboard(ev) {
/* copy a piece of text to clipboard. event.target is some DIV or SVG that is an icon.
* The attribute data-target of this element is the ID of the element that contains the text to copy.
* The text is either in the attribute data-copy or the innerText.
* see C function printCopyToClipboardButton(iconId, targetId);
* */
ev.preventDefault();
var buttonEl = ev.target.closest("button"); // user can click SVG or BUTTON element
var targetId = buttonEl.getAttribute("data-target");
if (targetId===null)
targetId = ev.target.parentNode.getAttribute("data-target");
var textEl = document.getElementById(targetId);
var text = textEl.getAttribute("data-copy");
if (text===null)
text = textEl.innerText;
var textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
buttonEl.innerHTML = 'Copied';
ev.preventDefault();
}
function cfgPageOnVisChange(ev) {
/* configuration page event listener when user changes visibility in dropdown */
if (ev.target.value === 'hide')
ev.target.classList.replace("normalText", "hiddenText");
else
ev.target.classList.replace("hiddenText", "normalText");
}
function cfgPageAddListeners() {
/* add event listener to dropdowns */
var els = document.querySelectorAll(".trackVis");
for (var i=0; i < els.length; i++) {
var el = els[i];
el.addEventListener("change", cfgPageOnVisChange );
}
}
// Google Analytics helper functions to send events, see src/hg/lib/googleAnalytics.c
function gaOnButtonClick(ev) {
/* user clicked a button: send event to GA, then execute the old handler */
var button = ev.currentTarget;
var buttonName = button.name;
if (buttonName==="")
buttonName = button.id;
if (buttonName==="")
buttonName = button.value;
// add the original label, makes logs a lot easier to read
buttonName = button.value + " / "+buttonName;
ga('send', 'event', 'buttonClick', buttonName);
if (button.oldOnClick) // most buttons did not have an onclick function at all (the default click is a listener)
{
button.oldOnClick(ev);
}
}
function gaTrackButtons() {
/* replace the click handler on all buttons with one the sends a GA event first, then handles the click */
if (!window.ga || ga.loaded) // When using an Adblocker, the ga object does not exist
return;
var buttons = document.querySelectorAll('input[type=submit],input[type=button]');
var isFF = theClient.isFirefox();
for (var i = 0; i < buttons.length; i++) {
var b = buttons[i];
// some old Firefox versions <= 78 do not allow submit buttons to also send AJAX requests
// so Zoom/Move buttons are skipped in FF (even though newer versions allow it again, certainly FF >= 90)
if (isFF && b.name.match(/\.out|\.in|\.left|\.right/))
continue;
b.oldOnClick = b.onclick;
b.onclick = gaOnButtonClick; // addEventHandler would not work here, the default click stops propagation.
}
}
// end Google Analytics helper functions
function clickIt(obj,state,force)
{
// calls click() for an object, and click();click() if force
if (obj.checked !== state) {
obj.click();
} else if (force) {
obj.click();
obj.click(); //force onclick event
}
}
function setCheckBoxesWithPrefix(obj, prefix, state)
{
// Set all checkboxes with given prefix to state boolean
var list = inputArrayThatMatches("checkbox","id",prefix,"");
for (var i=0;i 0 && inpType !== 'select' && ele.type !== inpType)
continue;
var identifier = ele.name;
if (nameOrId.search(/id/i) !== -1)
identifier = ele.id;
var failed = false;
if (prefix.length > 0)
failed = (identifier.indexOf(prefix) !== 0);
if (!failed && suffix.length > 0)
failed = (identifier.lastIndexOf(suffix) !== (identifier.length - suffix.length));
if (!failed) {
for (var aIx=4;aIx2)
alert("arrayOfInputsThatMatch is unimplemented for this browser");
}
return found;
}
function normed(thing)
{ // RETURNS undefined, the lone member of the set or the full set if more than one member.
// Used for normalizing returns from jquery DOM selects (e.g. $('tr.track').children('td.data'))
// jquery returns an "array like 'object'" with 0 or more entries.
// May be used on non-jquery objects and will reduce single element arrays to the element.
// Use this to treat 0 entries the same as undefined and 1 entry as the item itself
if (typeof(thing) === 'undefined' || thing === null
|| (thing.length !== undefined && thing.length === 0) // Empty array (or 'array like object')
|| ($.isPlainObject(thing) && $.isEmptyObject(thing))) // Empty simple object
return undefined;
if (thing.length && thing.length === 1 && typeof thing !== 'string') // string is overkill
return thing[0]; // Container of one item should return the item itself.
return thing;
}
var theClient = (function() {
// Object that detects client browser if requested
// - - - - - Private variables and/or methods - - - - -
var ieVersion = null;
var browserNamed = null;
// - - - - - Public methods - - - - -
return { // returns an object with public methods
getIeVersion: function ()
{ // Adapted from the web: stackOverflow.com answer by Joachim Isaksson
if (ieVersion === null) {
ieVersion = -1.0;
var re = null;
if (navigator.appName === 'Microsoft Internet Explorer') {
browserNamed = 'MSIE';
re = new RegExp("MSIE ([0-9]{1,}[.0-9]{0,})");
if (re.exec(navigator.userAgent) !== null)
ieVersion = parseFloat( RegExp.$1 );
} else if (navigator.appName === 'Netscape') {
re = new RegExp("Trident/.*rv:([0-9]{1,}[.0-9]{0,})");
if (re.exec(navigator.userAgent) !== null) {
browserNamed = 'MSIE';
ieVersion = parseFloat( RegExp.$1 );
}
}
}
return ieVersion;
},
isIePre11: function ()
{
var ieVersion = theClient.getIeVersion();
if ( ieVersion !== -1.0 && ieVersion < 11.0 )
return true;
return false;
},
isIePost11: function ()
{
if (theClient.getIeVersion() >= 11.0 )
return true;
return false;
},
isIe: function ()
{
if (theClient.getIeVersion() === -1.0 )
return false;
return true;
},
isChrome: function ()
{
if (browserNamed !== null)
return (browserNamed === 'Chrome');
if (navigator.userAgent.indexOf("Chrome") !== -1) {
browserNamed = 'Chrome';
return true;
}
return false;
},
isNetscape: function ()
{ // IE sometimes mimics netscape
if (browserNamed !== null)
return (browserNamed === 'Netscape');
if (navigator.appName === 'Netscape'
&& navigator.userAgent.indexOf("Trident") === -1) {
browserNamed = 'Netscape';
return true;
}
return false;
},
isFirefox: function ()
{
if (browserNamed !== null)
return (browserNamed === 'FF');
if (navigator.userAgent.indexOf("Firefox") !== -1) {
browserNamed = 'FF';
return true;
}
return false;
},
isSafari: function ()
{ // Chrome sometimes mimics Safari
if (browserNamed !== null)
return (browserNamed === 'Safari');
if (navigator.userAgent.indexOf("Safari") !== -1
&& navigator.userAgent.indexOf('Chrome') === -1) {
browserNamed = 'Safari';
return true;
}
return false;
},
isOpera: function ()
{
if (browserNamed !== null)
return (browserNamed === 'Opera');
if (navigator.userAgent.indexOf("Presto") !== -1) {
browserNamed = 'Opera';
return true;
}
return false;
},
nameIs: function ()
{ // simple enough, this needs no comment!
if (browserNamed === null
&& theClient.isChrome() === false // Looking in the order of popularity
&& theClient.isFirefox() === false
&& theClient.isIe() === false
&& theClient.isSafari() === false
&& theClient.isOpera() === false
&& theClient.isNetscape() === false)
browserNamed = navigator.appName; // Don't know what else to look for.
return browserNamed;
}
};
}());
function waitCursor(obj) // DEAD CODE?
{
//document.body.style.cursor="wait"
obj.style.cursor="wait";
}
function endWaitCursor(obj) // DEAD CODE?
{
obj.style.cursor="";
}
function getURLParam()
{
// Retrieve variable value from an url.
// Can be called either:
// getURLParam(url, name)
// or:
// getURLParam(name)
// Second interface will default to using window.location.href
var strHref, strParamName;
var strReturn = "";
if (arguments.length === 1) {
strHref = window.location.href;
strParamName = arguments[0];
} else {
strHref = arguments[0];
strParamName = arguments[1];
}
if ( strHref.indexOf("?") > -1) {
var strQueryString = strHref.substr(strHref.indexOf("?")).toLowerCase();
var aQueryString = strQueryString.split("&");
for (var iParam = 0; iParam < aQueryString.length; iParam++) {
if (aQueryString[iParam].indexOf(strParamName.toLowerCase() + "=") > -1) {
var aParam = aQueryString[iParam].split("=");
strReturn = aParam[1];
break;
}
}
}
return unescape(strReturn);
}
function makeHiddenInput(theForm,aName,aValue)
{ // Create a hidden input to hold a value
$(theForm).find("input:last").after("");
}
function updateOrMakeNamedVariable(theForm,aName,aValue)
{ // Store a value to a named input. Will make the input if necessary
var inp = normed($(theForm).find("input[name='"+aName+"']:last"));
if (inp) {
$(inp).val(aValue);
inp.disabled = false;
} else
makeHiddenInput(theForm,aName,aValue);
}
function disableNamedVariable(theForm,aName)
{ // Store a value to a named input. Will make the input if necessary
var inp = normed($(theForm).find("input[name='"+aName+"']:last"));
if (inp)
inp.disabled = true;
}
function parseUrlAndUpdateVars(theForm,href) // DEAD CODE?
{ // Parses the URL and converts GET vals to POST vals
var url = href;
var extraIx = url.indexOf("?");
if (extraIx > 0) {
var extra = url.substring(extraIx+1);
url = url.substring(0,extraIx);
// now extra must be repeatedly broken into name=var
extraIx = extra.indexOf("=");
for (; extraIx > 0;extraIx = extra.indexOf("=")) {
var aValue;
var aName = extra.substring(0,extraIx);
var endIx = extra.indexOf("&");
if (endIx>0) {
aValue = extra.substring(extraIx+1,endIx);
extra = extra.substring(endIx+1);
} else {
aValue = extra.substring(extraIx+1);
extra = "";
}
if (aName.length > 0 && aValue.length > 0)
updateOrMakeNamedVariable(theForm,aName,aValue);
}
}
return url;
}
function postTheForm(formName,href)
{ // posts the form with a passed in href
var goodForm = normed($("form[name='"+formName+"']"));
if (goodForm) {
if (href && href.length > 0) {
$(goodForm).attr('action',href); // just attach the straight href
}
$(goodForm).attr('method','POST');
$(goodForm).submit();
}
return false; // Meaning do not continue with anything else
}
function setVarAndPostForm(aName,aValue,formName)
{ // Sets a specific variable then posts
var goodForm = normed($("form[name='"+formName+"']"));
if (goodForm) {
updateOrMakeNamedVariable(goodForm,aName,aValue);
}
return postTheForm(formName,window.location.href);
}
// json help routines
function tdbGetJsonRecord(trackName) { return hgTracks.trackDb[trackName]; }
// NOTE: These must jive with tdbKindOfParent() and tdbKindOfChild() in trackDb.h
function tdbIsFolder(tdb) { return (tdb.kindOfParent === 1); }
function tdbIsComposite(tdb) { return (tdb.kindOfParent === 2); }
function tdbIsMultiTrack(tdb) { return (tdb.kindOfParent === 3); }
function tdbIsView(tdb) { return (tdb.kindOfParent === 4); } // Don't expect to use
function tdbIsContainer(tdb) { return (tdb.kindOfParent === 2 || tdb.kindOfParent === 3); }
function tdbIsLeaf(tdb) { return (tdb.kindOfParent === 0); }
function tdbIsFolderContent(tdb) { return (tdb.kindOfChild === 1); }
function tdbIsCompositeSubtrack(tdb) { return (tdb.kindOfChild === 2); }
function tdbIsMultiTrackSubtrack(tdb) { return (tdb.kindOfChild === 3); }
function tdbIsSubtrack(tdb) { return (tdb.kindOfChild === 2 || tdb.kindOfChild === 3); }
function tdbHasParent(tdb) { return (tdb.kindOfChild !== 0 && tdb.parentTrack); }
function parentIsAllWiggle(tdb)
{ // True if every subtrack of tdb's parent composite is a wiggle type.
if (!tdb.parentTrack)
return false;
var parentName = tdb.parentTrack, found = false;
for (var name in hgTracks.trackDb) {
var t = hgTracks.trackDb[name];
if (t.parentTrack === parentName) {
found = true;
if (/^wigMaf/.test(t.type) || !/^(wig|bigWig|bedGraph)/.test(t.type))
return false;
}
}
return found;
}
function cartHideAnyTrack (id, cartVars, cartVals) {
/* set the right cart variables to hide a track, changes cartVars and cartVals */
var rec = hgTracks.trackDb[id];
if (tdbIsSubtrack(rec)) {
cartVars.push(id);
cartVals.push('[]');
cartVars.push(id+"_sel");
cartVals.push(0);
} else if (tdbIsFolderContent(rec)) {
// supertrack children need to have _sel set to trigger superttrack reshaping
cartVars.push(id);
cartVals.push('hide');
cartVars.push(id+"_sel");
cartVals.push(0);
} else {
// normal, top-level track
cartVars.push(id);
cartVals.push('hide');
}
}
function tdbCountChildren(trackDb, parentType) {
/* return dicts with count of children, uses either the .parentTrack or .topParent trackDb attributes */
var familySize = {};
var families = {};
// sort trackDb into object topParent -> count of children
for (var trackName of Object.keys(hgTracks.trackDb)) {
var rec = hgTracks.trackDb[trackName];
// when looking at superTracks, only look at those children that are in superTracks
if (rec[parentType]===undefined) {
continue; // ignore tracks without parents
}
var parentTrack = rec[parentType];
if (!familySize.hasOwnProperty(parentTrack)) {
familySize[parentTrack] = 0;
families[parentTrack] = [];
}
familySize[parentTrack]++;
families[parentTrack].push(trackName);
}
var ret = {};
ret.familySize = familySize;
ret.families = families;
return ret;
}
function tdbFindChildless(trackDb, delTracks) {
/* Find parents that have no children left anymore in hgTracks.trackDb if you remove delTracks.
* return obj with o.loneParents as array of [parent, array of children] , and o.others as an array of all other tracks
* The caller needs the children names, as we want to hide the children with Javascript right away */
// This functions uses a somewhat weird strategy: it counts the children of the composites, also counts the children of superTracks,
// and compares both at the end. There may be a better strategy but our data structures are so strange that I didn't know what
// else to do.
var parentType = "parentTrack";
others = [];
var compLinks = tdbCountChildren(trackDb, "parentTrack"); // look only at direct parents: superTracks and composites
var topLinks = tdbCountChildren(trackDb, "topParent"); // look at top parents, so only superTracks
// decrease the parent's count for each track to delete
for (var delTrack of delTracks) {
var tdbRec = hgTracks.trackDb[delTrack];
let parentName = tdbRec.parentTrack;
if (parentName)
compLinks.familySize[parentName]--;
parentName = tdbRec.topParent;
if (parentName)
topLinks.familySize[parentName]--;
}
// for the parents of deleted tracks with a count of 0, create an array of [parentName, children]
var loneParents = [];
var doneParents = [];
for (delTrack of delTracks) {
var parentName = hgTracks.trackDb[delTrack].parentTrack;
var topParentName = hgTracks.trackDb[delTrack].topParent;
if (parentName) {
// hide a superTrack parent only if that superTrack does not have any other tracks open
// This addresses the case where you hide the last child of a composite under a superTrack
// E.g. All Gencode / Gencode V46 or FANTOM5/Max count
if (compLinks.familySize[parentName]===0 && topLinks.familySize[topParentName]===0) {
if (!doneParents.includes(parentName)) {
loneParents.push([topParentName, compLinks.families[parentName]]);
doneParents.push(topParentName);
}
} else
for (var child of compLinks.families[parentName])
// do not hide tracks of lone parents that are not in delTracks
if (delTracks.includes(child))
others.push(child);
} else {
others.push(delTrack);
}
}
o = {};
o.loneParents = loneParents;
o.others = Array.from(new Set(others)); // remove duplicates (rare bug in the above)
return o;
}
function aryFind(ary,val)
{// returns the index of a value on the array or -1;
for (var ix=0; ix < ary.length; ix++) {
if (ary[ix] === val) {
return ix;
}
}
return -1;
}
function aryRemove(ary,vals)
{ // removes one or more variables that are found in the array
for (var vIx=0; vIx < vals.length; vIx++) {
var ix = aryFind(ary,vals[vIx]);
if (ix !== -1)
ary.splice(ix,1);
}
return ary;
}
function arysToObj(names,values)
{ // Make hash type obj with two parallel arrays.
var obj = {};
for (var ix=0; ix < names.length; ix++) {
obj[names[ix]] = values[ix];
}
return obj;
}
function objNotEmpty(obj)
{ // returns true on non empty object. Obj should pass $.isPlainObject()
if ($.isPlainObject(obj) === false)
warn("Only use plain js objects in objNotEmpty()");
return ($.isEmptyObject(obj) === false);
}
function objKeyCount(obj)
{ // returns number of keys in object.
if (!Object.keys) {
var count = 0;
for (var key in obj) {
count++;
}
return count;
} else
return Object.keys(obj).length;
}
function isInteger(s)
{
return (!isNaN(parseInt(s)) && isFinite(s) && s.toString().indexOf('.') < 0);
}
function isFloat(s)
{
return (!isNaN(parseFloat(s)) && isFinite(s));
}
function validateInt(obj,min,max)
{ // validates an integer which may be restricted to a range (if min and/or max are numbers)
var title = obj.title;
var rangeMin=parseInt(min);
var rangeMax=parseInt(max);
if (title.length === 0)
title = "Value";
var popup=( theClient.isIePre11() === false );
for (;;) {
if ((obj.value === undefined || obj.value === null || obj.value === "")
&& isInteger(obj.defaultValue))
obj.value = obj.defaultValue;
if (!isInteger(obj.value)) {
if (popup) {
obj.value = prompt(title +" is invalid.\nMust be an integer.",obj.value);
continue;
} else {
alert(title +" of '"+obj.value +"' is invalid.\nMust be an integer.");
obj.value = obj.defaultValue;
return false;
}
}
var val = parseInt(obj.value);
if (isInteger(min) && isInteger(max)) {
if (val < rangeMin || val > rangeMax) {
if (popup) {
obj.value = prompt(title +" is invalid.\nMust be between "+rangeMin+
" and "+rangeMax+".",obj.value);
continue;
} else {
alert(title +" of '"+obj.value +"' is invalid.\nMust be between "+
rangeMin+" and "+rangeMax+".");
obj.value = obj.defaultValue;
return false;
}
}
} else if (isInteger(min)) {
if (val < rangeMin) {
if (popup) {
obj.value = prompt(title +" is invalid.\nMust be no less than "+
rangeMin+".",obj.value);
continue;
} else {
alert(title +" of '"+obj.value +"' is invalid.\nMust be no less than "+
rangeMin+".");
obj.value = obj.defaultValue;
return false;
}
}
} else if (isInteger(max)) {
if (val > rangeMax) {
if (popup) {
obj.value = prompt(title +" is invalid.\nMust be no greater than "+
rangeMax+".",obj.value);
continue;
} else {
alert(title +" of '"+obj.value +"' is invalid.\nMust be no greater than "+
rangeMax+".");
obj.value = obj.defaultValue;
return false;
}
}
}
return true;
}
}
function validateFloat(obj,min,max)
{ // validates an float which may be restricted to a range (if min and/or max are numbers)
var title = obj.title;
var rangeMin=parseFloat(min);
var rangeMax=parseFloat(max);
if (title.length === 0)
title = "Value";
var popup=( theClient.isIePre11() === false );
for (;;) {
if ((obj.value === undefined || obj.value === null || obj.value === "")
&& isFloat(obj.defaultValue))
obj.value = obj.defaultValue;
if (!isFloat(obj.value)) {
if (popup) {
obj.value = prompt(title +" is invalid.\nMust be a number.",obj.value);
continue;
} else {
alert(title +" of '"+obj.value +"' is invalid.\nMust be a number."); // try a prompt box!
obj.value = obj.defaultValue;
return false;
}
}
var val = parseFloat(obj.value);
if (isFloat(min) && isFloat(max)) {
if (val < rangeMin || val > rangeMax) {
if (popup) {
obj.value = prompt(title +" is invalid.\nMust be between "+rangeMin+" and "+
rangeMax+".",obj.value);
continue;
} else {
alert(title +" of '"+obj.value +"' is invalid.\nMust be between "+rangeMin+
" and "+rangeMax+".");
obj.value = obj.defaultValue;
return false;
}
}
} else if (isFloat(min)) {
if (val < rangeMin) {
if (popup) {
obj.value = prompt(title +" is invalid.\nMust be no less than "+rangeMin+
".",obj.value);
continue;
} else {
alert(title +" of '"+obj.value +"' is invalid.\nMust be no less than "+
rangeMin+".");
obj.value = obj.defaultValue;
return false;
}
}
} else if (isFloat(max)) {
if (val > rangeMax) {
if (popup) {
obj.value = prompt(title +" is invalid.\nMust be no greater than "+
rangeMax+".",obj.value);
continue;
} else {
alert(title +" of '"+obj.value +"' is invalid.\nMust be no greater than "+
rangeMax+".");
obj.value = obj.defaultValue;
return false;
}
}
}
return true;
}
}
function validateLabel(label)
{ // returns true if label is valid in trackDb as short or long label
var regexp = /^[a-z][ a-z0-9/'!\$()*,\-.:;<=>?@\[\]^_`{|}~]*$/i;
if (regexp.test(label)) {
return true;
} else {
alert(label + " is an invalid label. The first character must be alphabetical and the rest of the string be alphanumeric or the following puncuation ~`!@$/^*.()_-=[{]}?|;:'<,>");
return false;
}
}
function validateUrl(url)
{ // returns true if url is a valid url, otherwise returns false and shows an alert
// I got this regexp from http://stackoverflow.com/questions/1303872/url-validation-using-javascript
var regexp = /^(https?|ftp|gs|s3|drs):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i;
if (regexp.test(url)) {
return true;
} else {
alert(url + " is an invalid url");
return false;
}
}
function metadataIsVisible(trackName)
{
var divit = normed($("#div_"+trackName+"_meta"));
if (!divit)
return false;
return ($(divit).css('display') !== 'none');
}
function metadataShowHide(trackName,showLonglabel,showShortLabel)
{
// Will show subtrack specific configuration controls
// Config controls not matching name will be hidden
var divit = normed($("#div_"+trackName+"_meta"));
if (!divit)
return false;
var img = normed($(divit).prev('a').find("img"));
if (img) {
if ($(divit).css('display') === 'none')
$(img).attr('src','../images/upBlue.png');
else
$(img).attr('src','../images/downBlue.png');
}
if ($(divit).css('display') === 'none') {
if (typeof(subCfg) === "object") {// subCfg.js file included?
var cfg = normed($("#div_cfg_"+trackName));
if (cfg) // Hide any configuration when opening metadata
$(cfg).hide();
}
}
var tr = $(divit).parents('tr');
if (tr.length > 0) {
tr = tr[0];
var bgClass = null;
var classes = $( tr ).attr("class").split(" ");
for (var ix=0;ix 5)
newVal = arguments[5] ? 0 : 1;
var oldSrc = $(button).attr("src");
if (oldSrc && oldSrc.length > 0) {
// Old img version of the toggleButton
if (newVal === -1)
newVal = oldSrc.indexOf("/remove") > 0 ? 1 : 0;
if (newVal === 1)
$(button).attr("src", oldSrc.replace("/remove", "/add") );
else
$(button).attr("src", oldSrc.replace("/add", "/remove") );
} else {
// new BUTTONS_BY_CSS
if (newVal === -1) {
oldSrc = $(button).text();
if (oldSrc && oldSrc.length === 1)
newVal = (oldSrc === '+') ? 0 : 1;
else {
warn("Uninterpretable toggleButton!");
newVal = 0;
}
}
if (newVal === 1)
$(button).text('+');
else
$(button).text('-');
}
var contents = $("tr[id^='"+prefix+"-']");
if(newVal === 1) {
$(button).attr('title', 'Expand this '+titleDesc);
contents.hide();
} else {
$(button).attr('title', 'Collapse this '+titleDesc);
contents.show().trigger('show');
}
$(hidden).val(newVal);
if (doAjax) {
setCartVar(hiddenPrefix+"_"+prefix+"_close", newVal);
}
retval = false;
}
return retval;
}
function getNonce(debug)
{ // Gets nonce value from page meta header
var content = $("meta[http-equiv='Content-Security-Policy']").attr("content");
if (!content)
return "";
// parse nonce like 'nonce-JDPiW8odQkiav4UCeXsa34ElFm7o'
var sectionBegin = "'nonce-";
var sectionEnd = "'";
var ix = content.indexOf(sectionBegin);
if (ix < 0)
return "";
content = content.substring(ix+sectionBegin.length);
ix = content.indexOf(sectionEnd);
if (ix < 0)
return "";
content = content.substring(0,ix);
if (debug)
alert('page nonce='+content);
return content;
}
function notifBoxShow(cgiName, keyName) {
/* move the notification bar div under '#TrackHeaderForm' */
let lsKey = cgiName + "_" + keyName;
if (localStorage.getItem(lsKey))
return;
var notifEl = document.getElementById(lsKey + "notifBox");
if (!notifEl) {
// missing call to setup function (ie generated server side like a udcTimeout message)
notifBoxSetup(cgiName, keyName);
}
// TODO: make a generic element for positioning this
var parentEl = document.getElementById('TrackHeaderForm');
if (parentEl) {
parentEl.appendChild(notifEl);
notifEl.style.display = 'block';
}
}
function notifBoxSetup(cgiName, keyName, msg, skipCloseButton) {
/* Create a notification box if one hasn't been created, and
* add msg to the list of shown notifications.
* cgiName.keyName will be saved to localStorage in order to show
* or hide this notification.
* Must call notifBoxShow() in order to display the notification */
lsKey = cgiName + "_" + keyName;
if (localStorage.getItem(lsKey))
return;
let alreadyPresent = false;
let notifBox = document.getElementById(lsKey+"notifBox");
if (notifBox) {
alreadyPreset = true;
if (msg) {
notifBox.innerHTML += " " + msg;
}
} else {
notifBox = document.createElement("div");
notifBox.className = "notifBox";
notifBox.style.display = "none";
notifBox.style.width = "90%";
notifBox.style.marginLeft = "100px";
notifBox.id = lsKey+"notifBox";
if (msg) {
notifBox.innerHTML = msg;
}
}
var closeHtml = " ";
var buttonStyles = "text-align: center";
// XX code review: The close button does not sense at all, why not just always remove it?
if (skipCloseButton===true) {
closeHtml = "";
buttonStyles += "; display: inline; margin-left: 20px";
}
notifBox.innerHTML += "
"+
closeHtml +
""+
"
";
if (!alreadyPresent) {
document.body.appendChild(notifBox);
}
$("#"+lsKey+"notifyHide").click({"id":lsKey}, function() {
let key = arguments[0].data.id;
$("#"+key+"notifBox").remove();
});
$("#"+lsKey+"notifyHideForever").click({"id": lsKey}, function() {
let key = arguments[0].data.id;
$("#"+key+"notifBox").remove();
localStorage.setItem(key, "1");
});
}
function warnBoxJsSetup()
{ // Sets up warnBox if not already established. This is duplicated from htmshell.c
var html = "";
html += "
";
html += "
";
html += "
";
html += "
";
html += "
";
html += "
";
// GALT TODO either add nonce or move the showWarnBox and hideWarnBox to some universal javascript
// file that is always included. Or consider if we can just dynamically define the functions
// right here inside this function? maybe prepend function names with "window." to (re)define the global functions.
// maybe something like window.showWarnBox = function(){stuff here};
html += "";
$('body').prepend(html);
document.getElementById('warnOK').onclick = function() {hideWarnBox();return false;};
}
function warn(msg)
{ // adds warnings to the warnBox
var warnList = normed($('#warnList')); // warnBox contains warnList
if (!warnList) {
warnBoxJsSetup();
warnList = normed($('#warnList'));
}
if (!warnList)
alert(msg);
else {
// don't add warnings that already exist:
var oldMsgs = [];
$('#warnList li').each(function(i, elem) {
oldMsgs.push(elem.innerHTML);
});
// make the would-be new message into an
element so the case and quotes
// match any pre-existing ones
var newNode = document.createElement('li');
newNode.innerHTML = msg;
if (oldMsgs.indexOf(newNode.innerHTML) === -1) {
$( warnList ).append(newNode);
}
if ($.isFunction(showWarnBox))
showWarnBox();
else
alert(msg);
}
}
var gWarnSinceMSecs = 0;
function warnSince(msg) // DEAD CODE?
{ // Warn messages with msecs since last warnSince msg
// This is necessary because IE Developer tools are hanging
var now = new Date();
var msecs = now.getTime();
var since = 0;
if (gWarnSinceMSecs > 0)
since = msecs - gWarnSinceMSecs;
gWarnSinceMSecs = msecs;
warn('['+since+'] '+msg);
}
function cgiBooleanShadowPrefix()
// Prefix for shadow variable set with boolean variables.
// Exact copy of code in cheapcgi.c
{
return "boolshad.";
}
function getAllVars(obj,subtrackName)
{
// Returns a hash for all inputs and selects in an obj.
// If obj is undefined then obj is document!
var urlData = {};
if (!obj)
obj = $('document');
var inp = $(obj).find('input');
var sel = $(obj).find('select');
//warn("obj:"+$(obj).attr('id') + " inputs:"+$(inp).length+ " selects:"+$(sel).length);
$(inp).filter(':not([name^="boolshad"]):enabled').each(function (i) {
var name = $(this).attr('name');
var val = $(this).val();
if ($(this).attr('type') === 'checkbox' || $(this).attr('type') === "CHECKBOX") {
name = cgiBooleanShadowPrefix() + name;
val = $(this).prop('checked') ? 1 : 0;
} else if ($(this).attr('type') === 'radio') {
if (!$(this).prop('checked')) {
name = undefined;
}
}
if (name && name !== "Submit" && val !== undefined && val !== null) {
urlData[name] = val;
}
});
// special case the vcfSampleOrder variable because it is a hidden input type that
// changes based on click-drag
$(inp).filter('[name$="vcfSampleOrder"]').each(function (i) {
var name = $(this).attr('name');
var val = $(this).val();
if (name && name !== "Submit" && val !== undefined && val !== null) {
urlData[name] = val;
}
});
// special case the highlight/color picker inputs
$(inp).filter('[id^=colorPicker],[id^=colorInput]').each(function(i) {
// remove the prefix that lets us recognize this setting, what remains
// is specific to the track or for the back end
var name = $(this)[0].id.replace("colorPicker.", "").replace("colorInput.", "");
var val = $(this).val();
urlData[name] = val;
});
$(sel).filter('[name]:enabled').each(function (i) {
var name = $(this).attr('name');
var val = $(this).val();
if (name && val !== undefined && val !== null) {
if (subtrackName && name === subtrackName) {
if (val === 'hide') {
urlData[name+"_sel"] = 0; // Can't delete "_sel" because default takes over
urlData[name] = "[]"; // Can delete vis because
} else { // subtrack vis should be inherited.
urlData[name+"_sel"] = 1;
urlData[name] = val;
}
} else {
if (Array.isArray( val) && val.length > 1) {
urlData[name] = "[" + val.toString() + "]";
} else
urlData[name] = val;
}
}
});
return urlData;
}
function debugDumpFormCollection(collectionName,vars)
{ // dumps form vars collection in an alert
var debugStr = "";
for (var thisVar in vars) {
debugStr += thisVar + "==" + vars[thisVar]+"\n";
}
alert("DEBUG "+ collectionName + ":\n"+debugStr);
}
function varHashChanges(newVars,oldVars)
{ // Returns a hash of all vars that are changed between old and new hash.
// New vars not found in old are changed.
var changedVars = {};
for (var newVar in newVars) {
if (oldVars[newVar] === null || oldVars[newVar] !== newVars[newVar])
changedVars[newVar] = newVars[newVar];
}
return changedVars;
}
function varHashToQueryString(varHash)
{
// return a CGI QUERY_STRING for name/vals in given object
var retVal = "";
var count = 0;
for (var aVar in varHash) {
if (count++ > 0) {
retVal += "&";
}
var val = varHash[aVar];
if (typeof(val) === 'string'
&& val.length >= 2
&& val.indexOf('[') === 0
&& val.lastIndexOf(']') === (val.length - 1)) {
var vals = val.substr(1,val.length - 2).split(',');
/* jshint loopfunc: true */// function inside loop works and replacement is awkward.
$(vals).each(function (ix) {
if (ix > 0)
retVal += "&";
retVal += aVar + "=" + encodeURIComponent(this);
});
} else if (typeof(val) === 'string') {
// sometimes val is already encoded or partially encoded
// the CGI cannot know if user input is double encoded
// so test for already encoded characters here and only
// encode what we need to
retVal += aVar + "=" + val.replace(/(%[0-9A-Fa-f]{2})+|[^%]+/g, (match) => {
if (/%[0-9A-Fa-f]{2}/.test(match)) {
// Already percent-encoded, leave as-is
return match;
}
// Encode unencoded parts
return encodeURIComponent(match);
});
} else {
retVal += aVar + "=" + encodeURIComponent(val);
}
}
return retVal;
}
function getAllVarsAsUrlData(obj) // DEAD CODE?
{
// Returns a string in the form of var1=val1&var2=val2... for all inputs and selects in an obj
// If obj is undefined then obj is document!
return varHashToQueryString(getAllVars(obj));
}
/*
function popupBox(popit, content, popTitle)
{
// Kicks off a Modal Dialog for the provided content.
// Requires jquery-ui.js
// NEEDS SOME WORK
warn(content);
// Set up the popit div if necessary
if (!popit) {
popit = $('#popit');
if (!popit ) {
$('body').prepend("");
popit = $('#popit');
}
}
// Set up the modal dialog
$(popit).html("
" + content + "
");
$(popit).dialog({
ajaxOptions: {
// This doesn't work
cache: true
},
resizable: true,
bgiframe: true,
height: 'auto',
width: 'auto',
minHeight: 200,
minWidth: 400,
modal: true,
closeOnEscape: true,
autoOpen: false,
close: function() { // clear out html after close to prevent
$(popDiv).empty(); // problems caused by duplicate html elements
}
});
// Apparently the options above to dialog take only once, so we set title explicitly.
if (popTitle && popTitle.length > 0)
$(popit).dialog('option' , 'title' , popTitle );
else
$(popit).dialog('option' , 'title' , "Please Respond");
jQuery('body').css('cursor', '');
$(popit).dialog('open');
}
*/
function embedBoxOpen(boxit, content, reenterable) // DEAD CODE?
{ // embeds a box for the provided content.
// This box has 1 button (close) by default and 2 buttons if the name of an applyFunc
// is provided (apply, cancel) If there is no apply function, the box may be reentrent,
// meaning subsequent calls do not need to provide content
// NOTE: 4 extra STRING Params: boxWidth, boxTitle, applyFunc, applyName
// Define extra params now
var boxWidth = "80%";
var boxTitle = "";
var applyFunc = "";
var applyName = "Apply";
if (arguments.length > 3 && arguments[3].length > 0) // FIXME: could check type
boxWidth = arguments[3];
if (arguments.length > 4 && arguments[4].length > 0)
boxTitle = arguments[4];
if (arguments.length > 5 && arguments[5].length > 0)
applyFunc = arguments[5];
if (arguments.length > 6 && arguments[6].length > 0)
applyName = arguments[6];
// Set up the popit div if necessary
if (!boxit) {
boxit = $('div#boxit');
if (!boxit) {
$('body').prepend("");
//$('body').prepend("");
boxit = $('div#boxit');
}
}
if (!reenterable || (content.length > 0)) { // Can reenter without changing content!
var buildHtml = "
";
if (boxTitle.length > 0)
buildHtml += "
" + boxTitle + "
";
buildHtml += "
" + content + "
";
// Set up closing code
var closeButton = "Close";
var closeHtml = "embedBoxClose($(\"#"+ $(boxit).attr('id') + "\"),";
if (reenterable && applyFunc.length === 0)
closeHtml += "true);";
else
closeHtml += "false);";
// Buttons
buildHtml += "
";
if (applyFunc.length > 0) { // "Apply" button and "Cancel" button. Apply also closes!
buildHtml += " ";
closeButton = "Cancel"; // If apply button then close is cancel
}
buildHtml += "
";
$(boxit).html("
" + buildHtml + "
"); // Make it boxed
}
var boxedHtml = $(boxit).html();
if (!boxedHtml || boxedHtml.length === 0)
warn("embedHtmlBox() called without content");
else
$(boxit).show();
}
function embedBoxClose(boxit, reenterable) // DEAD CODE?
{ // Close an embedded box
// NOTE 4 extra STRING Params: boxWidth, boxTitle, applyFunc, applyName
if (boxit) {
$(boxit).hide();
if (!reenterable)
$(boxit).empty();
}
}
function startTiming() // DEAD CODE?
{
var now = new Date();
return now.getTime();
}
function showTiming(start,whatTookSoLong) // DEAD CODE?
{
var now = new Date();
var end = (now.getTime() - start);
warn(whatTookSoLong+" took "+end+" msecs.");
return end;
}
function getHgsid()
{// return current session id
// .first() because hgTracks turned up 3 of these!
var hgsid = normed($("input[name='hgsid']").first());
if (hgsid)
return hgsid.value;
hgsid = getURLParam(window.location.href, "hgsid");
if (hgsid.length > 0)
return hgsid;
// This may be moved to 1st position as the most likely source
if (typeof(common) !== 'undefined' && common.hgsid !== undefined && common.hgsid !== null)
return common.hgsid;
hgsid = normed($("input#hgsid").first());
if (hgsid)
return hgsid.value;
return "";
}
function undecoratedDb(db)
// return the db name with any hub_id_ stripped
{
var retDb = db;
if (db.startsWith("hub_")) {
retDb = db.split('_').slice(2).join('_');
}
return retDb;
}
function getDb()
{
var db = normed($("input[name='db']").first());
if (db)
return db.value;
db = getURLParam(window.location.href, "db");
if (db.length > 0)
return db;
// This may be moved to 1st position as the most likely source
if (typeof(common) !== 'undefined' && common.db)
return common.db;
db = normed($("input#db").first());
if (db)
return db.value;
if (typeof uiState !== "undefined" && uiState.db)
return uiState.db;
db = document.getElementById("selectAssembly");
if (db)
return db.selectedOptions[0].value;
return "";
}
function undecoratedTrack(track)
// return the track name with any hub_id_ stripped
{
var retTrack = track;
if (track.startsWith("hub_")) {
retTrack = track.split('_').slice(2).join("_");
}
return retTrack;
}
function getTrack()
{
var track = normed($("input#g").first());
if (track)
return track.value;
track = normed($("input[name='g']").first());
if (track)
return track.value;
track = getURLParam(window.location.href, "g");
if (track.length > 0)
return track;
// This may be moved to 1st position as the most likely source
if (typeof(common) !== 'undefined' && common.track)
return common.track;
return "";
}
function Rectangle() // DEAD CODE?
{
// Rectangle object constructor:
// calling syntax:
//
// new Rectangle(startX, endX, startY, endY)
// new Rectangle(coords) <-- coordinate string from an area item
if (arguments.length === 4) {
this.startX = arguments[0];
this.endX = arguments[1];
this.startY = arguments[2];
this.endY = arguments[3];
} else if (arguments.length > 0) {
var coords = arguments[0].split(",");
this.startX = coords[0];
this.endX = coords[2];
this.startY = coords[1];
this.endY = coords[3];
} else { // what else to do?
this.startX = 0;
this.endX = 100;
this.startY = 0;
this.endY = 100;
}
}
Rectangle.prototype.contains = function(x, y) // DEAD CODE?
{
// returns true if given points are in the rectangle
var retval = x >= this.startX && x <= this.endX && y >= this.startY && y <= this.endY;
return retval;
};
function commify (str) {
if (typeof(str) === "number")
str = str + "";
var n = str.length;
if (n <= 3) {
return str;
} else {
var pre = str.substring(0, n-3);
var post = str.substring(n-3);
pre = commify(pre);
return pre + "," + post;
}
}
function parsePosition(position)
// Parse chr:start-end string into a chrom, start, end object
{
if (position && position.length > 0) {
position = position.replace(/,/g, "");
var a = /(\S+):(\d+)-(\d+)/.exec(position);
if (a && a.length === 4) {
var o = {};
o.chrom = a[1];
o.start = parseInt(a[2]);
o.end = parseInt(a[3]);
return o;
}
}
return null;
}
function makeHighlightString(db, chrom, start, end, color) {
/* given db and a range on it and a color (color must be prefixed by #),
* return the highlight string in the cart for it. See parsePositionWithDb for the history
* of the various accepted highlight strings */
return db+"#"+chrom+"#"+start+"#"+end+color;
}
function parsePositionWithDb(position)
// returns an object with chrom, start, end and optionally color attributes
// position is a string and can be in one of five different formats:
// 0) chr:start-end
// 1) db.chr:start-end
// 2) db.chr:start-end#color
// 3) db#chr#start#end#color
// Formats 0-2 are only supported for backwards compatibility with old carts
{
var out = {};
var parts = null;
if (position.split("#").length !==5 ) {
// formats of old carts: 0-2
parts = position.split(".");
// handle the db part
if (parts.length === 2) {
out.db = parts[0];
position = parts[1];
} else {
out.db = getDb(); // default the db
}
// position now contains chr:start-end#color
parts = position.split("#"); // Highlight Region may carry its color
if (parts.length === 2) {
position = parts[0];
out.color = '#' + parts[1];
}
var pos = parsePosition(position);
if (pos) {
out.chrom = pos.chrom;
out.start = pos.start;
out.end = pos.end;
}
} else {
// new format
parts = position.split("#");
out.db = parts[0];
out.chrom = parts[1];
out.start = parseInt(parts[2]);
out.end = parseInt(parts[3]);
out.color = "#" + parts[4];
}
return out;
}
function getHighlight(highlightStr, index)
/* Parse out highlight at index and return as a position object (see parsePositionWithDb) */
{
var hlStrings = highlightStr.split("|");
var myHlStr = hlStrings[index];
var posObj = parsePositionWithDb(myHlStr);
return posObj;
}
function getSizeFromCoordinates(position)
{
// Parse size out of a chr:start-end string
var o = parsePosition(position);
if (o) {
return o.end - o.start + 1;
}
return null;
}
// This code is intended to allow setting up a wait cursor while waiting on the function
var gWaitFuncArgs = [];
var gWaitFunc;
function waitMaskClear()
{ // Clears the waitMask
var waitMask = normed($('#waitMask'));
if (waitMask)
$(waitMask).hide();
}
function waitMaskSetup(timeOutInMs)
{ // Sets up the waitMask to block page manipulation until cleared
// Find or create the waitMask (which masks the whole page)
var waitMask = normed($('#waitMask'));
if (!waitMask) {
// create the waitMask
$("body").append("");
waitMask = normed($('#waitMask'));
}
$(waitMask).css({opacity:0.0,display:'block',top: '0px',
height: $(document).height().toString() + 'px' });
// Special for IE, since it takes so long, make mask obvious
//if (theClient.isIePre11())
// $(waitMask).css({opacity:0.4,backgroundColor:'gray'});
// Things could fail, so always have a timeout.
if (!timeOutInMs) // works for undefined, null and 0
timeOutInMs = 30000; // IE can take forever!
if (timeOutInMs > 0)
setTimeout(waitMaskClear,timeOutInMs); // Just in case
return waitMask; // The caller could add css if they wanted.
}
function _launchWaitOnFunction()
{ // should ONLY be called by waitOnFunction()
// Launches the saved function
var func = gWaitFunc;
gWaitFunc = null;
var funcArgs = gWaitFuncArgs;
gWaitFuncArgs = [];
if (!func || !jQuery.isFunction(func))
warn("_launchWaitOnFunction called without a function");
else
func.apply(this, funcArgs);
// Special if the first var is a button that can visually be inset
if (funcArgs.length > 0 && funcArgs[0].type) {
if (funcArgs[0].type === 'button' && $(funcArgs[0]).hasClass('inOutButton')) {
$(funcArgs[0]).css('borderStyle',"outset");
}
}
// Now we can get rid of the wait cursor
waitMaskClear();
}
function waitOnFunction(func)
{ // sets the waitMask (wait cursor and no clicking),
// then launches the function with up to 5 arguments
if (!jQuery.isFunction(func)) {
warn("waitOnFunction called without a function");
return false;
}
if (gWaitFunc) {
if (gWaitFunc === func) // already called (sometimes hapens when onchange event is triggered
return true; // by js (rather than direct user action). Happens in IE8
warn("waitOnFunction called but already waiting on a function");
return false;
}
waitMaskSetup(0); // Find or create waitMask (which masks whole page) but gives up after 5sec
// Special if the first var is a button that can visually be inset
if (arguments.length > 1 && arguments[1].type) {
if (arguments[1].type === 'button' && $(arguments[1]).hasClass('inOutButton')) {
$(arguments[1]).css( 'borderStyle',"inset");
}
}
// Build up the aruments array
for (var aIx=1; aIx < arguments.length; aIx++) {
gWaitFuncArgs.push(arguments[aIx]);
}
gWaitFunc = func;
setTimeout(_launchWaitOnFunction,10);
}
// --- yielding iterator ---
function _yieldingIteratorObject(yieldingFunc) // DEAD CODE?
{ // This is the "recusive object" or ro which is instantiated in waitOnIteratingFunction
// yieldingFunc is passed in from waitOnIteratingFunction
// and will recurse which recursively calls an iterator
this.step = function(msecs,args) {
setTimeout(function() { yieldingFunc(args); }, msecs); // recursive timeouts
return;
};
}
function yieldingIterator(iteratingFunc,continuingFunc,args) // DEAD CODE?
{ // Will run iteratingFunc function with "yields", then run continuingFunc
// Based upon design by Guido Tapia, PicNet
// iteratingFunc must return number of msecs to pause before next interation.
// return 0 ends iteration with call to continuingFunc
// return < 0 ends iteration with no call to continuingFunc
// Both iteratingFunc and continuingFunc will receive the single "args" param.
// Hint. for multiple args, create a single struct object
var ro = new _yieldingIteratorObject(function() {
var msecs = iteratingFunc(args);
if (msecs > 0)
ro.step(msecs,args); // recursion
else if (msecs === 0)
continuingFunc(args); // completion
// else (msec < 0) // abandon
});
ro.step(1,args); // kick-off
}
function showLoadingImage(id)
// Show a loading image above the given id; return's id of div added (allowing later removal).
{
var loadingId = id + "LoadingOverlay";
var overlay = $("");
var ele = $(document.getElementById(id));
overlay.appendTo("body");
var divLeft = ele.position().left + 2;
var width = ele.width() - 1;
var height = ele.height();
overlay.width(width);
overlay.height(height);
overlay.css({top: (ele.position().top + 1) + 'px', left: divLeft + 'px'});
return loadingId;
}
function hideLoadingImage(id)
{
$(document.getElementById(id)).remove();
}
function codonColoringChanged(name)
{ // Updated disabled state of codonNumbering checkbox based on current value
// of track coloring select.
var val = $("select[name='" + name + ".baseColorDrawOpt'] option:selected").text();
$("input[name='" + name + ".codonNumbering']").attr('disabled', val === "OFF");
$("#" + name + "CodonNumberingLabel").toggleClass("disabled", val === "OFF" ? true : false);
}
function gtexTransformChanged(name)
{ // Disable view limits settings if log transform enabled
// NOTE: selector strings are a bit complex due to dots GB vars/attributes (track.var)
// so can't use more concise jQuery syntax
// check log transform
var logCheckbox = $("input[name='" + name + ".logTransform']");
var isLogChecked = logCheckbox.attr('checked');
// enable/disable view limits
var maxTextbox = $("input[name='" + name + ".maxViewLimit']");
maxTextbox.attr('disabled', isLogChecked);
var maxTextLabel = $("." + name + "ViewLimitsMaxLabel");
maxTextLabel.toggleClass("disabled", isLogChecked ? true : false);
}
function barChartUiTransformChanged(name) {
// Disable view limits settings if log transform enabled
gtexTransformChanged(name);
}
function gtexSamplesChanged(name)
{ // Disable and comparison controls if all samples selected
// check sample select
var sampleSelect = $("input[name='" + name + ".samples']:checked");
var isAllSamples = (sampleSelect.val() === 'all');
// enable/disable comparison options
// limiting to radio buttons as there is a problem with tissue checkbox naming on popup
var comparisonButtons = $("input[type='radio' name='" + name + ".comparison']");
comparisonButtons.attr('disabled', isAllSamples);
var comparisonLabel = $("." + name + "ComparisonLabel");
comparisonLabel.toggleClass("disabled", isAllSamples ? true : false);
}
var bindings = {
// This object is for finding a subtring using tokens as bounds
// The tokens can be literal strings or regular expressions.
// If regular expressions are used, then only the first expression found will count
// If not using regexp, then you can pass in limits to the original string
_raw: function (begToken,endToken,someString,ixBeg,ixEnd)
{ // primitive not meant to be called directly but by bindings.inside and bindings.outside
if (someString.length <= 0)
return '';
if (ixBeg === undefined || ixBeg === null)
ixBeg = 0;
if (ixEnd === undefined || ixEnd === null)
ixEnd = someString.length;
var insideBeg = ixBeg;
var insideEnd = ixEnd;
if (begToken.constructor.name === "RegExp")
insideBeg = someString.search(begToken);
else if (begToken.length > 0)
insideBeg = someString.indexOf(begToken,ixBeg);
if (endToken.constructor.name === "RegExp")
insideEnd = someString.search(endToken);
else if (endToken.length > 0)
insideEnd = someString.indexOf(endToken,ixBeg);
if (ixBeg <= insideBeg && insideBeg <= insideEnd && insideEnd <= ixEnd)
return {start : insideBeg, stop : insideEnd};
return {start : -1, stop : -1};
},
inside: function (begToken,endToken,someString,ixBeg,ixEnd)
{ // returns the inside bounds of 2 tokens within a string
// Note ixBeg and ixEnd are optional bounds already established within string
// Pattern match can be used instead of literal token if a regexp is passed in for the tokens
var bounds = bindings._raw(begToken,endToken,someString,ixBeg,ixEnd);
if (bounds.start > -1) {
if (begToken.constructor.name === "RegExp")
bounds.start += someString.match(begToken)[0].length;
else
bounds.start += begToken.length;
}
return bounds;
},
outside: function (begToken,endToken,someString,ixBeg,ixEnd)
{ // returns the outside bounds of 2 tokens within a string
// Note ixBeg and ixEnd are optional bounds already established within string
// Pattern match can be used instead of literal token if a regexp is passed in for the tokens
var bounds = bindings._raw(begToken,endToken,someString,ixBeg,ixEnd);
if (bounds.start > -1) {
if (endToken.constructor.name === "RegExp")
bounds.stop += someString.match(endToken)[0].length;
else
bounds.stop += endToken.length;
}
return bounds;
},
insideOut: function (begToken,endToken,someString,ixBeg,ixEnd)
{ // returns what falls between begToken and endToken as found in the string provided
// Note ixBeg and ixEnd are optional bounds already established within string
var bounds = bindings.inside(begToken,endToken,someString,ixBeg,ixEnd);
if (bounds.start < bounds.stop)
return someString.slice(bounds.start,bounds.stop);
return '';
}
};
function stripHgErrors(returnedHtml, whatWeDid)
{ // strips HGERROR style 'early errors' and shows them in the warnBox
// If whatWeDid !== null, we use it to return info about what we stripped out and
// processed (current just warnMsg).
var cleanHtml = returnedHtml;
var begToken = '';
var endToken = '';
while (cleanHtml.length > 0) {
var bounds = bindings.outside(begToken,endToken,cleanHtml);
if (bounds.start === -1)
break;
// OLD WAY var warnMsg = bindings.insideOut('
','
',cleanHtml,bounds.start,bounds.stop);
var warnMsg = cleanHtml.slice(bounds.start+begToken.length,bounds.stop-endToken.length);
if (warnMsg.length > 0) {
warn(warnMsg);
if (whatWeDid)
whatWeDid.warnMsg = warnMsg;
}
cleanHtml = cleanHtml.slice(0,bounds.start) + cleanHtml.slice(bounds.stop);
}
return cleanHtml;
}
function stripJsFiles(returnedHtml, debug, whatWeDid)
{ // strips javascript files from html returned by ajax
var cleanHtml = returnedHtml;
// Match script tags with src attribute - handles single/double quotes, with/without type attr
var shlurpPattern=/";
var lastIx = 0;
while (1) {
var ix = html.indexOf(sectionBegin, lastIx);
if (ix < 0)
break;
var ix2 = ix + sectionBegin.length;
var ex = html.indexOf(sectionEnd, ix2);
if (ex < 0)
break;
content += html.substring(lastIx,ix);
var jsNonce = html.substring(ix2,ex);
if (debug)
alert("jsNonce:"+jsNonce);
results.push(jsNonce);
lastIx = ex + sectionEnd.length;
}
// grab the last piece.
content += html.substring(lastIx);
//return results;
if (whatWeDid)
whatWeDid.js = results;
return content;
}
function charsAreHex(s)
// are all the chars found hex?
{
var hexChars = "01234566789abcdefABCDEF";
var d = false;
var i = 0;
if (s) {
d = true;
while (i < s.length) {
if (hexChars.indexOf(s.charAt(i++)) < 0)
d = false;
}
}
return d;
}
function nonAlphaNumericHexDecodeText(s, prefix, postfix)
// For html tag attributes, it decodes non-alphanumeric characters
// with HH hex codes.
// Decoding happens in-place, changing the input string s.
// prefix must not be empty string or null, but postfix can be empty string.
// Because the decoded string is always equal to or shorter than the input string,
// the decoding is just done in-place modifying the input string.
// Accepts upper and lower case values in entities.
//
{
var d = "";
var pfxLen = prefix.length;
var postLen = postfix.length;
var i = 0;
if (s) {
while (i < s.length) {
var matched = false;
if (i+pfxLen+postLen+2 <= s.length) {
var pre = s.substr(i, pfxLen).toLowerCase();
if (pre === prefix) {
var post = s.substr(i+pfxLen+2, postLen).toLowerCase();
if (post === postfix) {
var hex = s.substr(i+pfxLen, 2);
if (charsAreHex(hex)) {
d = d + String.fromCharCode(parseInt(hex,16));
i += pfxLen + 2 + postLen;
matched = true;
}
}
}
}
if (!matched)
d = d + s.charAt(i++);
}
}
return d;
}
function jsDecode(s)
// For JS string values decode "\xHH"
{
return nonAlphaNumericHexDecodeText(s, "\\x", "");
}
function stripCSPAndNonceJs(content, debug, whatWeDid)
// Strip CSP Header and script blocks with the ajax nonce.
{
var pageNonce = getNonce(debug);
var csp = {};
content = stripCspHeader(content, debug, csp);
var ajaxNonce = parseNonce(csp.csp, debug);
var jsBlocks = {};
content = stripJsNonce(content, ajaxNonce, debug, jsBlocks);
if (whatWeDid) {
whatWeDid.pageNonce = pageNonce;
whatWeDid.ajaxNonce = ajaxNonce; // Not in use yet.
whatWeDid.js = jsBlocks.js;
}
return stripHgErrors(content, whatWeDid); // Certain early errors are not called via warnBox
}
function appendNonceJsToPage(jsNonce)
// Append ajax js blocks with nonce.
// Create jsNonce by calling stripCSPAndNonceJs.
// Call this after ajax html content has been added to the page/DOM.
{
var i;
for (i=0; i 0) {
var begPattern = //i;
var endPattern = /<\/script\>/i;
var bounds = bindings.outside(begPattern,endPattern,cleanHtml);
if (bounds.start === -1)
break;
var jsEmbeded = cleanHtml.slice(bounds.start,bounds.stop);
if (-1 === jsEmbeded.indexOf("showWarnBox")) {
if (debug)
alert("jsEmbedded:'"+jsEmbeded+"'\n---------------\n"+cleanHtml);
} else {
var warnMsg = bindings.insideOut('
','
',cleanHtml,bounds.start,bounds.stop);
if (warnMsg.length > 0) {
warnMsg = jsDecode(warnMsg);
warn(warnMsg);
if (whatWeDid)
whatWeDid.warnMsg = warnMsg;
}
}
cleanHtml = cleanHtml.slice(0,bounds.start) + cleanHtml.slice(bounds.stop);
}
return stripHgErrors(cleanHtml, whatWeDid); // Certain early errors are not called via warnBox
}
function stripMainMenu(returnedHtml, debug, whatWeDid)
{ // strips main menu div from html returned by ajax
// NOTE: any warnBox style errors will be put into the warnBox
// If whatWeDid !== null, we use it to return info about
// what we stripped out and processed (current just warnMsg).
var cleanHtml = returnedHtml;
// embedded javascript?
while (cleanHtml.length > 0) {
var begPattern = '
';
var endPattern = '
';
var bounds = bindings.outside(begPattern,endPattern,cleanHtml);
if (bounds.start === -1)
break;
var mainMenu = cleanHtml.slice(bounds.start,bounds.stop);
if (-1 === mainMenu.indexOf("showWarnBox")) {
if (debug)
alert("mainMenu:'"+mainMenu+"'\n---------------\n"+cleanHtml);
} else {
var warnMsg = bindings.insideOut('
','
',cleanHtml,bounds.start,bounds.stop);
if (warnMsg.length > 0) {
warn(warnMsg);
if (whatWeDid)
whatWeDid.warnMsg = warnMsg;
}
}
cleanHtml = cleanHtml.slice(0,bounds.start) + cleanHtml.slice(bounds.stop);
}
return stripHgErrors(cleanHtml, whatWeDid); // Certain early errors are not called via warnBox
}
function visTriggersHiddenSelect(obj)
{ // SuperTrack child changing vis should trigger superTrack reshaping.
// This is done by setting hidden input "_sel"
var trackName_Sel = $(obj).attr('name') + "_sel";
var theForm = $(obj).closest("form");
var visible = (obj.selectedIndex !== 0);
if (visible) {
updateOrMakeNamedVariable(theForm,trackName_Sel,"1");
} else
disableNamedVariable(theForm,trackName_Sel);
return true;
}
function setCheckboxList(list, value)
{ // set value of all checkboxes in semicolon delimited list
var names = list.split(";");
for (var i=0; i < names.length; i++) {
$("input[name='" + names[i] + "']").attr('checked', value);
}
}
function calculateHgTracksWidth()
{
// return appropriate width for hgTracks image given users current window width
return $(window).width() - 20;
}
function addPixAndReloadPage()
/* users who do not come in from hgGateway have no pix variable in the URL nor the cart.
* This is a rare case, and the solution is brute force: if it happens, set pix, then reload the entire page.
* This will only happen once to these users, as afterwards the cookie is set. */
{
var winWidth = calculateHgTracksWidth();
var myUrl = window.location.href;
var sep = '?';
if (myUrl.indexOf('?')!==-1)
sep = '&';
var newUrl = myUrl+sep+"pix="+winWidth;
window.location.href = newUrl;
}
function hgTracksSetWidth()
{
var winWidth = calculateHgTracksWidth();
if ($("#imgTbl").length === 0) {
// XXXX what's this code for?
$("#TrackForm").append('');
//$("#TrackForm").submit();
} else {
$("input[name=pix]").val(winWidth);
}
}
function filterByMaxHeight(multiSel)
{ // Setting a max height to scroll dropdownchecklists but
// multiSel is hidden when this is done, so it's position and height must be estimated.
var pos = $(multiSel).closest(':visible').offset().top + 30;
if (pos <= 0)
pos = 260;
// Special mess since the filterBy's on non-current tabs will calculate pos badly.
var tabbed = normed($('input#currentTab'));
if (tabbed) {
var tabDiv = $(multiSel).parents('div#'+ $(tabbed).attr('value'));
if (!tabDiv || $(tabDiv).length === 0) {
pos = 360;
}
}
var maxHeight = $(window).height() - pos;
var selHeight = ($(multiSel).children().length + 1) * 22;
if (maxHeight > selHeight)
maxHeight = null;
return maxHeight;
}
//////////// Drag and Drop ////////////
function tableDragAndDropRegister(thisTable)
{// Initialize a table with tableWithDragAndDrop
if ($(thisTable).hasClass("tableWithDragAndDrop") === false)
return;
$(thisTable).tableDnD({
onDragClass: "trDrag",
dragHandle: "dragHandle",
onDrop: function(table, row, dragStartIndex) {
if (row.rowIndex !== dragStartIndex) {
if (sortTable.savePositions) {
sortTable.savePositions(table);
}
}
}
});
$(thisTable).find("td.dragHandle").hover(
function(){ $(this).closest('tr').addClass('trDrag'); },
function(){ $(this).closest('tr').removeClass('trDrag'); }
);
}
///////////////////////////////
////////// Sort Table /////////
///////////////////////////////
var sortTable = {
// The sortTable object handles sorting HTML tables on columns.
// Just add the 'sortable' class to your table and in ready() call
// sortTable.initialize($('table.sortable')).
//
// Details you don't need to know until you want to do something fancy.
// A sortable table requires:
// TABLE.sortable: TABLE class='sortable' containing a THEAD header and sortable TBODY filled
// with the rows to sort.
// THEAD.sortable: (NOTE: created if not found) THEAD can contain multiple rows must contain:
// TR.sortable: exactly 1 header TH (tr) class='sortable' which will declare the sort columns:
// TH.sortable: 1 or more TH (column headers) with class='sortable sort1 [sortRev]'
// (or sort2, sort3) declaring sort order and whether reversed. e.g.:
//
...
// (this means that factor is currently the third sort column and reverse sorted)
// NOTE: If no TH.sortable is found, then every th in the TR.sortable will be converted
// for you and will be in sort1,2,3 order.
// ONCLICK: Each TH.sortable must call sortTable.sortOnButtonPress(this) directly
// or indirectly in the onclick event e.g.:
//
// NOTE: onclick function will automatically be added if not found.
// SUP: Each TH.sortable *may* contain a which will be filled with an
// up or down arrow and the column's sort order: e.g. ↓2
// NOTE: The sup can be added via the addSuperscript option in sortTable.initialize().
// TBODY.sortable: (NOTE: created if not found) The TBODY class='sortable' contains the
// table rows that get sorted:
// TBODY->TR & ->TD: Each row contains a TD for each sortable column.
// The innerHTML (entire contents) of the cell will be used for sorting.
// TRICK: You can use the 'abbr' field to subtly alter the sortable contents.
// Otherwise sorts on td contents ($(td).text()). Use the abbr field to make
// case-insensitive sorts or force exceptions to alpha-text order
// (such as. ZCTRL vs Control forcing controls to bottom) e.g.:
//
Control
// This is also the method to ensure a numeric sort e.g.:
//
3.2 GB
// IMPORTANT: You must add abbr='use' to the TH.sortable definitions.
// Finally if you want the tableSort to alternate the table row colors (using #FFFEE8 and #FFF9D2)
// then TBODY.sortable should also have class 'altColors'
// NOTE: This class can be added by using the altColors option to sortTable.initialize().
//
// PRESERVING TO CART: To send the sort column on a form 'submit', the header tr (TR.sortable)
// needs a named hidden input of class='sortOrder' as:
//
// AND each sortable column header (TH.sortable) must have id='{name}' which is the name of
// the sortable field (e.g. 'factor', 'shortLabel'). The value preserves the column sort order
// and direction based upon the id={name} of each sort column. In the example, while 'cell' may
// be the first column, the table is currently reverse ordered by 'factor', then by cell and view.
// And to send the sorted row orders on form 'submit', each TBODY->TR will need a named hidden
// input field of class='trPos'. e.g.:
//
// A reason to preserve the order in the cart is if the order will affect other cgis.
// For instance: sort subtracks and see that order in the hgTracks image.
// Sorting a table by columns relies upon the columns obj, whose C equivalent would look like:
//struct column
// {
// char * tags[]; // a list of field names in sort order (e.g. 'cell', 'shortLabel')
// boolean reverse[]; // the sort direction for each sort field
// int cellIxs[]; // The indexes of the columns in the table to be sorted
// boolean useAbbr[]; // Compare on Abbr or on text()?
// };
// These 2 globals are used during setTimeout, so that rows can be hidden while sorting
// and javascript timeout on slow (IE) browsers is less likely
columns: null,
tbody: null,
loadingId: null,
caseSensitive: false, // sorts are case INSENSITIVE by default
sortCaseSensitive: function (sensitive)
{ // set case senstivity, which can be added to each sortable columnn's onclick event.
// or set for the whole table right after initialize()
sortTable.caseSensitive = sensitive;
},
row: function (tr,sortColumns)
{
this.fields = [];
this.reverse = [];
this.row = tr;
for (var ix=0; ix < sortColumns.cellIxs.length; ix++)
{
var cell = tr.cells[sortColumns.cellIxs[ix]];
this.fields[ix] = (sortColumns.useAbbr[ix] ? cell.abbr : $(cell).text());
if (!sortTable.caseSensitive)
this.fields[ix] = this.fields[ix].toLowerCase(); // case insensitive sorts
this.reverse[ix] = sortColumns.reverse[ix];
}
},
rowCmp: function (a,b)
{
for (var ix=0; ix < a.fields.length; ix++) {
if (a.fields[ix] > b.fields[ix])
return (a.reverse[ix] ? -1:1);
else if (a.fields[ix] < b.fields[ix])
return (a.reverse[ix] ? 1:-1);
}
return 0;
},
field: function (value,reverse,row)
{
if (sortTable.caseSensitive || typeof(value) !== 'string')
this.value = value;
else
this.value = value.toLowerCase(); // case insensitive sorts
this.reverse = reverse;
this.row = row;
},
fieldCmp: function (a,b)
{
if (a.value > b.value)
return (a.reverse ? -1:1);
else if (a.value < b.value)
return (a.reverse ? 1:-1);
return 0;
},
sort: function (tbody,sortColumns)
{// Sorts table based upon rules passed in by function reference
// Expects tbody to not sort thead, but could take table
// The sort routine available is javascript array.sort(), which cannot sort rows directly
// Until we have jQuery >=v1.4, we cannot easily convert tbody.rows[] inot a javascript array
// So we will make our own array, sort, then then walk through the table and reorder
// FIXME: Until better methods are developed, only sortOrder based sorts are supported
// and fnCompare is obsolete
// Create an array of rows to sort
var rows = [];
var trs = tbody.rows;
$(trs).each(function(ix) {
rows.push(new sortTable.row(this, sortColumns));
});
// Sort the array
rows.sort(sortTable.rowCmp);
// most efficient reload of sorted rows I have found
var sortedRows = jQuery.map(rows, function(row, i) {
return row.row;
});
$(tbody).append(sortedRows);
sortTable.tbody=tbody;
sortTable.columns=sortColumns;
// Avoid js timeout
setTimeout(function() {
sortTable.sortFinish(sortTable.tbody,sortTable.columns);
},5);
},
sortFinish: function (tbody,sortColumns)
{ // Additional sort cleanup.
// This is in a separate function to allow calling with setTimeout() which will
// prevent javascript timeouts (I hope)
sortTable.savePositions(tbody);
if ($(tbody).hasClass('altColors'))
sortTable.alternateColors(tbody,sortColumns);
$(tbody).parents("table.tableWithDragAndDrop").each(function (ix) {
tableDragAndDropRegister(this);
});
if (sortTable.loadingId)
hideLoadingImage(sortTable.loadingId);
},
sortByColumns: function (tbody,sortColumns)
{ // Will sort the table based on the abbr values on a set of
// (this means that factor is currently the third sort column and reverse sorted)
// NOTE: If no TH.sortable is found, then every th in the TR.sortable will be
// converted for you and will be in sort1,2,3 order.)
// ONCLICK: Each TH.sortable must call sortTable.sortOnButtonPress(this) directly or
// indirectly in the onclick event. e.g.:
//
// NOTE: onclick function will automatically be added if not found.
// SUP: Each TH.sortable *may* contain a which will be filled with an up or down
// arrow and the column's sort order: e.g. ↓2
// NOTE: If no sup is found but addSuperscript is requested, then they will be added.
// TBODY.sortable: (NOTE: created if not found) The TBODY class='sortable' contains the
// table rows that get sorted:
// TBODY->TR & ->TD: Each row contains a TD for each sortable column.
// The innerHTML (entire contents) of the cell will be used for sorting.
// TRICK: You can use the 'abbr' field to subtly alter the sortable contents.
// Otherwise sorts on td contents ($(td).text()). Use the abbr field to make
// case-insensitive sorts or force exceptions to alpha-text order
// (such as ZCTRL vs Control forcing controls to bottom). e.g.:
//
';
// Build a self-contained modal so this helper does not depend on any page-local markup.
$('#gbTimingBg').remove();
var html = '
' +
'
' +
'
Timing
' +
'
Wall-clock time spent handling this ' +
'request. Add or remove &measureTiming=1 from the page URL to toggle ' +
'this report.
' +
body +
'
';
$('body').append(html);
convertTitleTagsToMouseovers();
function close() {
$('#gbTimingBg').remove();
$(document).off('keydown.gbTiming');
}
$('#gbTimingClose').on('click', close);
$('#gbTimingBg').on('click', function(ev) {
if (ev.target === this) // backdrop click only, not clicks inside the card
close();
});
$(document).on('keydown.gbTiming', function(ev) {
if (ev.key === "Escape")
close();
});
}
function htmlEncode(s) {
/* HTML-escape a value (&, <, >, ", ') so it is safe to insert as text or into an attribute
* value in a string of HTML. Shared helper: prefer this over rolling a per-file escaper.
* Uses the browser's own text->markup conversion via a detached element (jQuery required).
* That conversion only escapes &, < and > - quotes need no escaping in text, so it leaves them
* alone - hence the explicit quote handling below. Without it this function silently failed
* the "safe in an attribute" half of its contract: a value containing a double quote closed the
* attribute early, truncating it (and worse, allowing markup injection). */
return $('
').text(s === null || s === undefined ? '' : String(s)).html()
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function convertTitleTagsToMouseovers() {
/* make all the title tags in the document have mouseovers */
document.querySelectorAll("[title],[data-tooltip]").forEach(function(a, i) {
if (a.id !== "" && (a.id === "hotkeyHelp" || a.id.endsWith("Dialog") || a.id.endsWith("Popup"))) {
// these divs are populated by ui-dialog, they should not have tooltips
return;
}
if (a.title !== undefined &&
(a.title.startsWith("click & drag to scroll") || a.title.startsWith("drag select or click to zoom")))
a.title = "";
else if ((a.title !== undefined && a.title.length > 0) || a.getAttribute("data-tooltip") !== null) {
if (a.title && a.title.startsWith("Click to alter the display density")) {
// these tooltips have a longer delay:
a.setAttribute("tooltipDelay", "delayed");
}
titleTagToMouseover(a);
}
});
/* Mouseover should clear if you leave the document window altogether */
document.body.addEventListener("mouseleave", (ev) => {
if (mouseoverContainer) {
hideMouseoverText(mouseoverContainer);
}
});
/* Mouseover should clear on scroll */
document.addEventListener("scroll", (ev) => {
if (mouseoverContainer) {
hideMouseoverText(mouseoverContainer);
}
});
function hideTooltips() {
if (mouseoverContainer) {
hideMouseoverText(mouseoverContainer);
}
}
/* make the mouseovers go away if we are in an input */
const inps = document.getElementsByTagName("input");
for (let inp of inps) {
if (!(inp.type == "hidden" || inp.type == "HIDDEN")) {
if (inp.type !== "submit") {
inp.addEventListener("focus", hideTooltips);
} else {
// the buttons are inputs that don't blur right away (or ever? I can't tell), so
// be sure to restore the tooltips when they are clicked
inp.addEventListener("click", hideTooltips);
}
}
}
/* on a select, we can hide the tooltip on focus, but don't disable them
* altogether, because it's easy to click out of a select without actually
* losing focus, and we can't detect that because the web browser handles
* that click separately */
const sels = document.getElementsByTagName("select");
for (let sel of sels) {
sel.addEventListener("focus", hideTooltips);
for (let opt of sel.options) {
opt.addEventListener("click", hideTooltips);
}
}
/* Make the ESC key hide tooltips */
document.body.addEventListener("keyup", (ev) => {
if (ev.keyCode === 27) {
if (mouseoverContainer) {
hideMouseoverText(mouseoverContainer);
}
}
});
/* Make jquery-ui dialogs hide tooltips */
$(document).on("dialogopen", (ev) => {
hideMouseoverText(mouseoverContainer);
});
}
function parseUrl(url) {
// turn a url into some of it's components like server, query-string, etc
let protocol, serverName, pathInfo, queryString, queryArgs = {};
let temp;
temp = url.split("?");
if (temp.length > 1)
queryString = temp.slice(1).join("?");
temp = temp[0].split("/");
protocol = temp[0]; // "https:"
serverName = temp[2]; // "genome-test.gi.ucsc.edu"
pathInfo = temp.slice(3).join("/"); // "cgi-bin/hgTracks"
cgi = pathInfo.startsWith("cgi-bin") ? pathInfo.split('/')[1] : "";
let i, s = queryString ? queryString.split('&') : []; // Fix for when there is no query string in the URL
for (i = 0; i < s.length; i++) {
argVal = s[i].split('=');
queryArgs[argVal[0]] = argVal[1];
}
return {protocol: protocol, serverName: serverName, pathInfo: pathInfo, queryString: queryString, cgi: cgi, queryArgs: queryArgs};
}
function dumpCart(seconds, skipNotification) {
// dump current cart
let currUrl = parseUrl(window.location.href);
logUrl = currUrl.protocol + "//" + currUrl.serverName + "/" + currUrl.pathInfo + "?hgsid=" + getHgsid() + "&_dumpCart=" + encodeURIComponent(seconds) + "&skipNotif=" + skipNotification;
let xmlhttp = new XMLHttpRequest();
xmlhttp.open("GET", logUrl, true);
xmlhttp.send(); // sends request and exits this function
}
function writeToApacheLog(msg) {
// send msg to web servers error_log
// first need to figure out what server and CGI we are requesting:
let currUrl = parseUrl(window.location.href);
logUrl = currUrl.protocol + "//" + currUrl.serverName + "/" + currUrl.pathInfo + "?_dumpToLog=" + encodeURIComponent(msg);
let xmlhttp = new XMLHttpRequest();
xmlhttp.open("GET", logUrl, true);
xmlhttp.send(); // sends request and exits this function
}
function addRecentGenome(item) {
// Save a genome selection to localStorage for showing recent selections in species search.
// item is the autocomplete selection object with genome, label, commonName, category, etc.
// Keeps the 10 most recently selected genomes, ordered by recency.
const MAX_RECENT = 10;
let stored = window.localStorage.getItem("recentGenomes");
let recentObj = stored ? JSON.parse(stored) : {stack: [], results: {}};
let stack = recentObj.stack;
// Use db as the primary key so hg38 and hg19 don't overwrite each other (both have genome="Human")
// Fall back to genome for GenArk results which may not have a db field
let key = item.db || item.genome;
if (!key) return; // Can't save without an identifier
// Check if this key already exists
let existingItem = recentObj.results[key];
// Remove if already exists (will re-add at front)
if (stack.includes(key)) {
stack.splice(stack.indexOf(key), 1);
} else if (stack.length >= MAX_RECENT) {
// Remove oldest
let toDelete = stack.pop();
delete recentObj.results[toDelete];
}
// Create a clean copy of the item with HTML stripped from labels
// Start with the new item, then selectively preserve better data from existing item
let cleanItem = Object.assign({}, item);
if (cleanItem.label) {
cleanItem.label = cleanItem.label.replace(/<[^>]*>/g, '');
}
if (cleanItem.value) {
cleanItem.value = cleanItem.value.replace(/<[^>]*>/g, '');
}
// If this key already exists, preserve the more descriptive label and other important fields
// A label with parentheses or longer length is considered more descriptive
// (e.g., "Gorilla gorilla gorilla (gorGor3)" vs just "Gorilla")
if (existingItem) {
let existingLabel = existingItem.label || '';
let newLabel = cleanItem.label || '';
// Preserve the existing label if it's more descriptive
if (existingLabel.includes('(') || existingLabel.length > newLabel.length) {
cleanItem.label = existingLabel;
}
// Preserve category if not present in new item
if (!cleanItem.category && existingItem.category) {
cleanItem.category = existingItem.category;
}
// Preserve hubUrl if not present in new item
if (!cleanItem.hubUrl && existingItem.hubUrl) {
cleanItem.hubUrl = existingItem.hubUrl;
}
// Preserve hubName if not present in new item
if (!cleanItem.hubName && existingItem.hubName) {
cleanItem.hubName = existingItem.hubName;
}
}
// Add to front
stack.unshift(key);
recentObj.results[key] = cleanItem;
window.localStorage.setItem("recentGenomes", JSON.stringify(recentObj));
}
function getRecentGenomes() {
// Retrieve recent genome selections from localStorage, formatted for autocomplete display.
// Preserves original category for re-selection logic, adds displayCategory for UI.
let stored = window.localStorage.getItem("recentGenomes");
if (!stored) return [];
let recentObj = JSON.parse(stored);
let results = [];
for (let genome of recentObj.stack) {
if (recentObj.results[genome]) {
let item = Object.assign({}, recentObj.results[genome]);
// Preserve original category for setDbFromAutocomplete to detect GenArk/hubs
item.originalCategory = item.category;
// Set category for autocomplete grouping header
item.category = "Recent";
item.displayCategory = "Recent";
results.push(item);
}
}
return results;
}
function getPopularGenomes($inputEl) {
// Parse the popular assemblies JSON embedded in the page by printGenomeSearchBar().
// Returns an array of autocomplete-formatted items with category "Popular".
// The Popular list is deliberately NOT filtered against the recents: an assembly may appear
// under both headings. Suppressing the duplicate made entries shift position as soon as one
// was used, so the list a user had learned to click at kept rearranging itself under them.
// A stable Popular list is worth more than avoiding the repeat.
let inputId = $inputEl.attr('id');
let dataEl = document.getElementById(inputId + 'PopularData');
if (!dataEl) return [];
let popularData;
try {
popularData = JSON.parse(dataEl.textContent);
} catch (e) {
return [];
}
let results = [];
for (let p of popularData) {
results.push({
genome: p.db,
db: p.db,
label: p.label,
commonName: p.commonName,
category: "Popular",
displayCategory: "Popular"
});
}
return results;
}
function removeRecentGenomesByHubUrl(hubUrl) {
// Remove all recent genome entries whose hubUrl matches the given URL.
// Used when a hub is disconnected to clean up stale entries.
let stored = window.localStorage.getItem("recentGenomes");
if (!stored) return;
let recentObj = JSON.parse(stored);
let newStack = [];
for (let key of recentObj.stack) {
let item = recentObj.results[key];
if (item && item.hubUrl === hubUrl) {
delete recentObj.results[key];
} else {
newStack.push(key);
}
}
recentObj.stack = newStack;
window.localStorage.setItem("recentGenomes", JSON.stringify(recentObj));
}
function dbFromRecentItem(item) {
let db = item.db || item.genome;
if (item.hubName && item.category && item.category.startsWith('Assembly Hub'))
db = item.hubName + "_" + db;
return db;
}
function recentGenomeHref(res) {
// Build an hgTracks URL for a recent genome entry. GenArk assemblies are
// handled transparently by fixUpDb() in cartNew() when given just db= with
// the accession. UCSC native databases also just need db=.
let db = dbFromRecentItem(res);
let url = new URL("../cgi-bin/hgTracks", window.location.href);
url.searchParams.set("hgsid", getHgsid());
if (res.hubUrl) {
if (res.category && res.category.startsWith("Assembly Hub")) {
url.searchParams.set("hubUrl", res.hubUrl);
} else if (!res.category) {
// in practice this shouldn't happen, but just in case:
let msg = "recentGenomeHref: item has hubUrl but no category: " + JSON.stringify(res);
console.warn(msg);
writeToApacheLog(msg);
}
}
url.searchParams.set("db", db);
url.searchParams.set("position", "lastDbPos");
return url.toString();
}
function addRecentGenomesToMenuBar() {
// Retrieve recent genome selections from localStorage and add them to the "Genomes" menu heading
// Tries not add duplicate genomes
let stored = window.localStorage.getItem("recentGenomes");
if (!stored) return;
let recentObj = JSON.parse(stored);
let results = [];
for (let genome of recentObj.stack) {
if (recentObj.results[genome]) {
let item = document.createElement("li");
let link = document.createElement("a");
link.href = recentGenomeHref(recentObj.results[genome]);
link.textContent = recentObj.results[genome].label;
item.appendChild(link);
results.push(item);
}
}
// construct the current list of labels
const labelList = [];
document.querySelectorAll("#tools1 > ul > li > a").forEach( (a) => {
if (a.textContent.includes("/")) {
// try to extract just the 'hg38' or 'mm10' part
let splitLabel = a.textContent.split("/");
if (splitLabel[1].length > 0)
labelList.push(splitLabel[1]);
} else {
labelList.push(a.textContent);
}
});
// filter our list of recents against the list of "Genomes"
let finalResult = results.filter( (result) => {
return !labelList.some( (l) => {
return result.firstChild.textContent.includes(l);
});
});
// Only add separators and items if we have recents to add
if (finalResult.length === 0) return;
let lastLink = document.querySelector("#tools1 > ul > .last");
let parent = lastLink.parentNode;
// Add a separator after the default entries to separate from recents
let defaultLinks = parent.querySelectorAll("li.defaultDropdownLink");
if (defaultLinks.length > 0) {
let lastDefault = defaultLinks[defaultLinks.length - 1];
let separator = document.createElement("li");
separator.className = "horizSep noHighlight";
lastDefault.after(separator);
}
// Add a "Recent Genomes" title after the separator
let title = document.createElement("li");
title.className = "noHighlight";
let titleSpan = document.createElement("span");
titleSpan.textContent = "Recent Genomes";
titleSpan.style.cssText = "color: #888; font-size: 11px; padding: 4px 8px; display: block;";
title.appendChild(titleSpan);
parent.insertBefore(title, lastLink);
// Add a separator before the "Other" link to separate recents from it
let separatorBeforeOther = document.createElement("li");
separatorBeforeOther.className = "horizSep noHighlight";
parent.insertBefore(separatorBeforeOther, lastLink);
// Append the recent genomes above the "other" link (and its separator)
finalResult.forEach( (res) => {
parent.insertBefore(res, separatorBeforeOther);
});
}
function addRecentSearch(db, searchTerm, extra={}) {
// Push a searchTerm onto a stack in localStorage to show users their most recent
// search terms. If an optional extra argument is supplied (ex: the response from hgSuggest),
// save that as well
// The searchStack object (note: saved as a string via JSON.stringify in localStorage) keeps
// a per database stack of the 5 most recently searched terms, as well as their "result",
// which can be an autocomplete object from hgSuggest, something from hgSearch, or just nothing
// Example:
// var searchStack = {
// hg38: {
// "stack": ["foxp", "flag", "fla"],
// "results: {
// "foxp": {
// "value": "FOXP1 (Homo sap...",
// "id": "chr3:70954708-71583728",
// ...
// },
// "flag": {}, // NOTE: empty object
// "fla": {
// "value": ...,
// "id": ...,
// },
// }
// },
// mm10: {
// "stack": [...],
// "results": {},
// }
let searchStack = window.localStorage.getItem("searchStack");
let searchObj = {};
if (searchStack === null) {
searchObj[db] = {"stack": [searchTerm], "results": {}};
searchObj[db].results[searchTerm] = extra;
window.localStorage.setItem("searchStack", JSON.stringify(searchObj));
} else {
searchObj = JSON.parse(searchStack);
if (db in searchObj) {
let searchList = searchObj[db].stack;
if (searchList.includes(searchTerm)) {
// remove it from wherever it is cause it's going to the front
searchList.splice(searchList.indexOf(searchTerm), 1);
} else {
if (searchList.length >= 5) {
let toDelete = searchList.pop();
delete searchObj[db].results[toDelete];
}
}
// always update results in case the extra data has changed
searchObj[db].results[searchTerm] = extra;
searchList.unshift(searchTerm);
} else {
searchObj[db] = {"stack": [searchTerm], "results": {}};
searchObj[db].results[searchTerm] = extra;
}
window.localStorage.setItem("searchStack", JSON.stringify(searchObj));
}
}
// variables to parse url arguments correctly
var digitTest = /^\d+$/,
keyBreaker = /([^\[\]]+)|(\[\])/g,
plus = /\+/g,
paramTest = /([^?#]*)(#.*)?$/;
function deparam(params) {
/* From https://github.com/jupiterjs/jquerymx/blob/master/lang/string/deparam/deparam.js
* Used by the cell browser */
if(! params || ! paramTest.test(params) ) {
return {};
}
var data = {},
pairs = params.split('&'),
current;
for (var i=0; i < pairs.length; i++){
current = data;
var pair = pairs[i].split('=');
// if we find foo=1+1=2
if(pair.length !== 2) {
pair = [pair[0], pair.slice(1).join("=")];
}
var key = decodeURIComponent(pair[0].replace(plus, " ")),
value = decodeURIComponent(pair[1].replace(plus, " ")),
parts = key.match(keyBreaker);
for ( var j = 0; j < parts.length - 1; j++ ) {
var part = parts[j];
if (!current[part] ) {
//if what we are pointing to looks like an array
current[part] = digitTest.test(parts[j+1]) || parts[j+1] === "[]" ? [] : {};
}
current = current[part];
}
var lastPart = parts[parts.length - 1];
if (lastPart === "[]"){
current.push(value);
} else{
current[lastPart] = value;
}
}
return data;
}
function changeUrl(vars, oldVars) {
/* Save the users search string to the url so web browser can easily
* cache search results into the browser history
* vars: object of new key: val pairs like CGI arguments
* oldVars: arguments we want to keep between calls */
var myUrl = window.location.href;
myUrl = myUrl.replace('#','');
var urlParts = myUrl.split("?");
var baseUrl;
if (urlParts.length > 1)
baseUrl = urlParts[0];
else
baseUrl = myUrl;
var urlVars;
if (oldVars === undefined) {
var queryStr = urlParts[1];
urlVars = deparam(queryStr);
} else {
urlVars = oldVars;
}
for (var key in vars) {
var val = vars[key];
if (val === null || val === "") {
if (key in urlVars) {
delete urlVars[key];
}
} else {
urlVars[key] = val;
}
}
var argStr = jQuery.param(urlVars);
argStr = argStr.replace(/%20/g, "+");
return {"baseUrl": baseUrl, "args": argStr, "urlVars": urlVars};
}
function saveHistory(obj, urlParts, replace) {
/* Save an object to the web browser's history stack. When we visit the page for the
* first time, we replace the basic state after we are done making the initial UI */
if (replace) {
history.replaceState(obj, "", urlParts.baseUrl + (urlParts.args.length !== 0 ? "?" + urlParts.args : ""));
} else {
history.pushState(obj, "", urlParts.baseUrl + (urlParts.args.length !== 0 ? "?" + urlParts.args : ""));
}
}
function forceDisplayBedFile(url) {
var w = window.open('');
w.document.write(' ');
w.document.write('');
w.onload = function(ev) {
// Attach event listeners after the new window is loaded
w.document.getElementById('closeWindowLink').addEventListener('click', function() { w.close(); } );
};
fetch(url).then(response => response.text()) // Read the response as text
.then(function(text) {
w.document.write('
' + text + '
'); // Display the content
w.document.close(); // Close the document to finish rendering
})
.catch(error => console.error('Error fetching BED file:', error));
}
function processFindGenome(result, term) {
// process the hubApi/findGenome?q= result set into somthing
// jquery-ui autocomplete can use
let data = [];
let apiSkipList = new Set(["downloadTime", "downloadTimeStamp", "availableAssemblies", "browser", "elapsedTimeMs", "itemCount", "q", "totalMatchCount", "liftable"]);
Object.keys(result).forEach((key) => {
if (!(apiSkipList.has(key))) {
let val = result[key];
let d = {
"genome": key,
"label": `${val.commonName} (${key})`,
};
Object.keys(val).forEach((vkey) => {
d[vkey] = val[vkey];
});
// Set db to the key (database name or accession) so the autocomplete
// select handler can save it to recent genomes
d.db = key;
if (val.hubUrl !== null) {
d.category = "UCSC GenArk - bulk annotated assemblies from NCBI GenBank / Refseq";
} else {
d.category = "UCSC Genome Browser assemblies - annotation tracks curated by UCSC";
}
data.push(d);
}
});
return data;
}
function initSpeciesAutoCompleteDropdown(inputId, selectFunction, baseUrl = null,
watermark = null, onServerReply = null, onError = null, onFilterDropdown = null) {
/* Generic function for turning an element into a species search bar with an autocomplete
* list separating results by category.
* Required arguments:
* inputId: id of the input element itself, not created here
* selectFunction: the function to call when the user actually clicks on a result
* Optional arguments:
* baseUrl: where we send requests to which will return json we can parse into a list
* of results, defaults to 'hubApi/findGenome?browser=mustExist&q='
* watermark: placeholder text in the input
* onServerReply: function to run after querying baseUrl, by default use processFindGenome()
* to standardize on hubApi, but can be something else
* onError: function to call when the server returns an error (e.g. HTTP 400)
* signature: onError(jqXHR, textStatus, errorThrown, searchTerm) => results array or null
*/
let defaultSearchUrl = "hubApi/findGenome?browser=mustExist&q=";
$.widget("custom.autocompleteCat",
$.ui.autocomplete,
{
_renderMenu: function(ul, items) {
var that = this;
var currentCategory = "";
// There's no this._super as shown in the doc, so I can't override
// _create as shown in the doc -- just do this every time we render...
this.widget().menu("option", "items", "> :not(.ui-autocomplete-category)");
$(ul).css("z-index", "99999999");
// Check if all items are from recents (have displayCategory === "Recent")
// If so, skip category headers since they're all recent selections
var allRecent = items.length > 0 && items.every(function(item) {
return item.displayCategory === "Recent";
});
$.each(items, function(index, item) {
// Add a heading each time we see a new category (but not for recents)
if (!allRecent && item.category && item.category !== currentCategory) {
ul.append("
" +
item.category + "
" );
currentCategory = item.category;
}
that._renderItem( ul, item );
});
},
_renderItem: function(ul, item) {
// In order to use HTML markup in the autocomplete, one has to overwrite
// autocomplete's _renderItem method using .html instead of .text.
// http://forum.jquery.com/topic/using-html-in-autocomplete
// Hits to assembly hub top level (not individial db names) have no item label,
// so use the value instead
var searchTerm = this.term;
// remove special characters - the \W means remove anything
// that is not: [A-Za-z0-9_] which are 'word' == \w characters
// then eliminate runs of white space characters and trim any
// white space at the beginning or end of the string
var cleanTerm = searchTerm.replace(/\W/g, ' ')
.replace(/\s+/g, ' ')
.trim();
var label = item.label !== null ? item.label : item.value;
// Highlight matching search terms with bold tags
if (cleanTerm && cleanTerm.length > 0) {
// Split search term into individual words (by whitespace)
var words = cleanTerm.split(/\s+/).filter(function(word) {
return word.length > 0; // Filter out empty strings
});
// Apply bolding for each word separately
words.forEach(function(word) {
// Escape special regex characters in each word
var escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Create case-insensitive regex to find all occurrences
var regex = new RegExp('(' + escapedWord + ')', 'gi');
// Replace matches with bolded version (preserves original case)
label = label.replace(regex, '$1');
});
}
let $li = $("")
.data("ui-autocomplete-item", item)
.append($("").html(label))
.appendTo(ul);
if (item.disabled) {
$li.attr('title', item.disabledReason || 'Not available');
$li.css({'opacity': '0.5'});
$li.find('a').css({'pointer-events': 'none'});
}
return $li;
}
}
);
autocompleteCat.init($("[id='"+inputId+"']"), {
baseUrl: baseUrl !== null ? baseUrl : defaultSearchUrl,
watermark: watermark,
onSelect: selectFunction,
onServerReply: onServerReply !== null ? onServerReply : processFindGenome,
onError: onError,
onFilterDropdown: onFilterDropdown,
showRecentGenomes: true,
enterSelectsIdentical: false
});
}
function setupGenomeSearchBar(config) {
/* Higher-level wrapper for setting up a genome search bar with standard boilerplate.
* This handles the common pattern used across CGIs: error handling, DOMContentLoaded,
* element binding, search button handler, item validation, and label update.
*
* config object properties:
* inputId (required): ID of the search input element
* labelElementId: ID of the element to update with selected genome label (default: 'genomeLabel')
* onSelect: Callback function(item, labelElement) when genome is selected.
* Called AFTER standard validation and label update.
* item has: {genome, label, commonName, disabled, ...}
* apiUrl: Custom API URL (default: null uses standard hubApi/findGenome)
* onServerReply: Custom function to process API results (default: null uses processFindGenome)
*/
function onSearchError(jqXHR, textStatus, errorThrown, term) {
return [{label: 'No genomes found', value: '', genome: '', disabled: true}];
}
// What to put back in the search box if the user opens it and leaves without picking anything.
// Shared between wrappedSelect and the focus/blur pair below so that a genuine selection wins
// over the restore.
let restoreVal = null;
function wrappedSelect(labelElement, item) {
// Standard validation - all CGIs check this
if (item.disabled || !item.genome) return;
// Standard label update - all CGIs do this
if (labelElement)
labelElement.innerHTML = item.label;
// Make the search box show what was just picked. autoCompleteSelect() in
// autocompleteCat.js blurs the input, and jQuery UI's blur handler restores its last search
// term; when the list came from the recent/popular dropdown that term is the empty string
// (the toggle clears the input before searching), so the box would be left blank after
// choosing a recent genome. Deferring past that blur makes the selection stick.
let selInput = document.getElementById(config.inputId);
if (selInput) {
let text = (item.label || item.value || '').replace(/<[^>]*>/g, '');
restoreVal = text; // a real pick, so this is what blur should leave behind
setTimeout(function() { selInput.value = text; }, 0);
}
// Call user's custom callback for CGI-specific logic
if (typeof config.onSelect === 'function') {
config.onSelect(item, labelElement);
}
}
document.addEventListener("DOMContentLoaded", () => {
let labelElementId = config.labelElementId || 'genomeLabel';
let labelElement = document.getElementById(labelElementId);
let boundSelect = wrappedSelect.bind(null, labelElement);
initSpeciesAutoCompleteDropdown(config.inputId, boundSelect,
config.apiUrl || null, null, config.onServerReply || null, onSearchError);
// Opt-in (config.focusOpensDropdown): behave like a normal search box - focusing it selects
// whatever is in it, so typing replaces the current genome instead of appending to it, and
// opens the suggestion list right away. The source function treats an empty term as
// "show recent + popular", so this needs no special casing. Off by default so the CGIs
// that already use this widget keep their current behaviour.
if (config.focusOpensDropdown) {
let input = document.getElementById(config.inputId);
if (input) {
input.addEventListener("focus", () => {
// Empty the box before searching, exactly as the toggle button does. jQuery
// UI's search() takes its highlight term from the input's own value rather
// than the term passed in, so leaving a full assembly description in place
// bolds fragments of it ("Dec", "2013", ...) across unrelated entries further
// down the list. Clearing also makes this behave like an ordinary search box:
// the caret sits in an empty field ready for typing.
restoreVal = input.value;
input.value = "";
// Empty term is what the source function treats as "show recent + popular".
$("[id='" + config.inputId + "']").autocompleteCat("search", "");
});
input.addEventListener("blur", () => {
// Left without choosing anything: put back whatever was showing before, so the
// bar keeps naming the current assembly. A real pick has already updated
// restoreVal in wrappedSelect, so this restores the new genome, not the old.
if (!input.value && restoreVal) {
input.value = restoreVal;
}
});
}
}
// Standard search button handler
let btn = document.getElementById(config.inputId + "Button");
if (btn) {
btn.addEventListener("click", () => {
let val = document.getElementById(config.inputId).value;
$("[id='" + config.inputId + "']").autocompleteCat("search", val);
});
}
// Dropdown toggle button: opens/closes the autocomplete with recent+popular
let toggle = document.getElementById(config.inputId + "Toggle");
if (toggle) {
let wasOpen = false;
toggle.addEventListener("mousedown", () => {
let $input = $("[id='" + config.inputId + "']");
wasOpen = $input.autocompleteCat("widget").is(":visible");
});
toggle.addEventListener("click", () => {
let $input = $("[id='" + config.inputId + "']");
if (wasOpen) {
$input.autocompleteCat("close");
} else {
$input.val("");
$input.autocompleteCat("search", "");
$input.focus();
}
});
}
});
}
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
// check whether an autocomplete item is for a genark assembly
function isGenarkItem(item) {
return (typeof item.hubUrl !== "undefined" && item.hubUrl) &&
(typeof item.genome !== "undefined" && item.genome) &&
(item.genome.startsWith('GCA_') || item.genome.startsWith('GCF_'));
}
// ---------------------------------------------------------------------------
// setupGenomeSelector - combobox replacement for setupGenomeSearchBar
// ---------------------------------------------------------------------------
// Same call signature and same config contract as setupGenomeSearchBar, so a CGI switches over by
// changing only the function name in its setupGenomeSearchBar({...}) call. The markup still comes
// from printGenomeSearchBar() in hg/lib/web.c: this takes over the that emitted, re-houses
// it inside a combobox, and hides that widget's separate toggle / search button / info icon.
//
// Differences from setupGenomeSearchBar, per the Assembly Selector design (option 2a):
// - the caret sits *inside* the field, so it reads as one control with two ways in, rather than
// as a second control competing with the search box
// - the common assemblies appear as a row of short pills under the field, one click each
// - focusing the empty field opens the common list immediately; typing swaps the same panel to
// matching assemblies
// The suggestions themselves come from exactly where they did before: recent genomes from
// localStorage, popular ones from the JSON printGenomeSearchBar embeds, and everything else from
// hubApi/findGenome via processFindGenome.
var genomeSelectorStyleDone = false;
function genomeSelectorInjectStyle() {
/* One stylesheet for every selector on the page; injected rather than added to HGStyle.css so
* the component is self-contained and can be dropped into a CGI without a second edit. */
if (genomeSelectorStyleDone) { return; }
genomeSelectorStyleDone = true;
var css = `
.gsWrap { position:relative; max-width:620px; }
.gsField { display:flex; align-items:stretch; border:1px solid #9aa6b2; background:#fff; }
.gsField.gsFocus { border-color:#14487f; box-shadow:0 0 0 2px rgba(20,72,127,0.18); }
- .gsField input[type=text] { flex:1; min-width:0; border:0; outline:none; padding:11px 12px;
- font-size:16px; font-family:inherit; color:#23303f; background:transparent; }
+ .gsField input[type=text] { flex:1; min-width:0; border:0; outline:none; padding:9px 12px;
+ font-size:15px; font-family:inherit; color:#23303f; background:transparent; }
.gsCaret { border:0; border-left:1px solid #dbe1e7; background:#f4f7f9; width:42px;
display:flex; align-items:center; justify-content:center; cursor:pointer; color:#14487f; }
.gsCaret:hover { background:#e7edf2; }
.gsMenu { position:absolute; z-index:20; top:calc(100% + 4px); left:0; right:0; background:#fff;
border:1px solid #c3ccd5; box-shadow:0 8px 24px rgba(20,40,70,0.16); max-height:296px;
overflow:auto; display:none; }
.gsMenu.gsOpen { display:block; }
/* Tighter than the design's spacing: the mockup shows five rows, the real list runs to fifteen
* or more, so the padding that reads as comfortable there turns into a lot of scrolling here.
* Trimming the vertical padding fits roughly a third more rows in the same panel. */
.gsHead { padding:3px 12px; font-size:11.5px; font-weight:700; letter-spacing:0.06em;
text-transform:uppercase; color:#7c8896; background:#f4f7f9; border-bottom:1px solid #e6eaee;
position:sticky; top:0; }
.gsRow { display:flex; align-items:baseline; gap:8px; padding:3px 12px; cursor:pointer;
border-bottom:1px solid #f0f3f6; line-height:1.4; }
.gsRow.gsActive { background:#eef4fa; }
.gsRow .gsName { font-size:14px; color:#23303f; font-weight:600; }
.gsRow .gsSpecies { font-size:13px; color:#6b7885; font-style:italic; }
.gsRow .gsAcc { margin-left:auto; font-size:12.5px; color:#8b96a2; font-variant-numeric:tabular-nums; }
.gsEmpty { padding:10px 12px; font-size:14px; color:#6b7885; }
+ /* Shown only in the browse (common genomes) view: the user who opens the menu and does not
+ * find their species in the short list is exactly the one who needs to learn they can type. */
+ .gsHint { padding:6px 12px; font-size:12.5px; color:#6b7885; border-bottom:1px solid #e6eaee; }
.gsPicks { margin-top:10px; display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
.gsPicksLabel { font-size:13px; color:#6b7885; }
/* Smaller than the design's 14px and than the house minimum: these are short, high-contrast db
* names on a secondary row, and keeping them small stops the shortcuts from competing with the
* field above. Weight 600 holds legibility at this size. */
.gsPick { font-family:inherit; font-size:12.5px; font-weight:600; padding:3px 10px;
border:1px solid #b9c4ce; background:#fff; color:#14487f; cursor:pointer; border-radius:999px;
/* A GenArk db name is a full accession (GCA_018466985.1), far longer than "hg38"; clip it
* rather than let one recent assembly stretch the row. The tooltip has the full name. */
max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.gsPick:hover { border-color:#14487f; }
.gsPick.gsPickOn { background:#14487f; color:#fff; border-color:#14487f; }
.gsStatus { margin-top:10px; font-size:14px; color:#6b7885; min-height:20px; }
`;
var st = document.createElement('style');
st.id = 'genomeSelectorStyle';
st.textContent = css;
document.head.appendChild(st);
}
function genomeSelectorReady(fn) {
/* Run fn once the DOM is parsed, whether or not DOMContentLoaded has already fired - the CGIs
* emit their setup call at the end of the body, but a later caller should still work. */
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}
function setupGenomeSelector(config) {
/* Combobox genome/assembly picker. See the block comment above; config is the same object
* setupGenomeSearchBar takes:
* inputId (required), labelElementId, onSelect, apiUrl, onServerReply, onFilterDropdown
* plus, for this selector only:
* quickPickCount - how many pills to show under the field (default 5)
*/
genomeSelectorReady(function() {
var input = document.getElementById(config.inputId);
if (!input) { return; }
genomeSelectorInjectStyle();
var labelElement = document.getElementById(config.labelElementId || 'genomeLabel');
var searchUrl = config.apiUrl || 'hubApi/findGenome?browser=mustExist&q=';
var reply = config.onServerReply || processFindGenome;
var rows = []; // items currently in the menu
var active = -1; // keyboard-highlighted row
var committed = null; // the item last chosen, for highlighting the matching pill
// Has the user typed in the field since it was last filled in for them? If not, whatever
// it contains is a label the page or a selection put there, so focusing means "open the
// common list" rather than "search for this text" - a field pre-filled with
// "Dec. 2013 (GRCh38/hg38)" must not send that whole string to findGenome, which matches
// nothing. A flag rather than comparing against the initial value, because the caller may
// fill the field in after this setup runs (hgBlat does) and any captured value would be stale.
var userTyped = false;
var cache = {};
var timer = null;
// ---- build the shell around the existing input -----------------------------------
var wrap = document.createElement('div');
wrap.className = 'gsWrap';
var field = document.createElement('div');
field.className = 'gsField';
var caret = document.createElement('button');
caret.type = 'button';
caret.className = 'gsCaret';
caret.setAttribute('aria-label', 'Show common genomes');
caret.innerHTML = "";
var menu = document.createElement('div');
menu.className = 'gsMenu';
var picks = document.createElement('div');
picks.className = 'gsPicks';
// The "Selected: ..." line from the design is opt-in (config.showStatus). The field itself
// already shows the chosen assembly, and callers that want it elsewhere pass labelElementId,
// so by default this would be a redundant line reserving 20px of empty space under the pills.
var status = null;
if (config.showStatus) {
status = document.createElement('div');
status.className = 'gsStatus';
}
// printGenomeSearchBar wraps the input in .searchBarAndButton alongside a toggle, an
// optional search button and an info icon; this control replaces all three, so put the
// combobox where that wrapper was and drop the wrapper.
var oldBar = input.closest('.searchBarAndButton') || input.parentNode;
oldBar.parentNode.insertBefore(wrap, oldBar);
field.appendChild(input);
field.appendChild(caret);
wrap.appendChild(field);
wrap.appendChild(menu);
wrap.parentNode.insertBefore(picks, wrap.nextSibling);
if (status) { picks.parentNode.insertBefore(status, picks.nextSibling); }
oldBar.parentNode.removeChild(oldBar);
input.removeAttribute('size');
// ---- data ------------------------------------------------------------------------
function commonList() {
var recent = (typeof getRecentGenomes === 'function') ? getRecentGenomes() : [];
var popular = (typeof getPopularGenomes === 'function') ? getPopularGenomes($(input)) : [];
var all = recent.concat(popular);
if (typeof config.onFilterDropdown === 'function') { all = config.onFilterDropdown(all); }
return all;
}
function rowFor(item) {
// The API and the recent/popular lists carry different fields; fall back through them
// so a row always has something in each column rather than a gap.
return {
item: item,
name: item.label || item.genome || item.db || '',
species: item.scientificName || '',
acc: item.db || item.genome || ''
};
}
function render(heading, list, emptyMsg) {
// Items carry displayCategory ("Recent", "Popular"); start a new heading whenever it
// changes, so the two are told apart the way the old jQuery UI menu did. Server
// results have no category, so they all fall under the heading passed in.
rows = list.map(rowFor);
active = rows.length ? 0 : -1;
var html = '';
if (!rows.length) {
html = '
' + htmlEncode(heading) + '
' +
'
' + htmlEncode(emptyMsg) + '
';
} else {
var group = null;
rows.forEach(function(r, i) {
var g = r.item.displayCategory || heading;
if (g !== group) {
group = g;
html += '
' +
+ menu.innerHTML;
menu.classList.add('gsOpen');
}
function search(term) {
var t = term.trim();
if (!t) { openCommon(); return; }
if (t.length < 2) {
// One character is not worth a round trip; filter what is already in hand, which is
// what the jQuery UI version does too.
var hits = commonList().filter(function(d) {
return (d.label || '').toLowerCase().indexOf(t.toLowerCase()) >= 0 ||
(d.genome || '').toLowerCase().indexOf(t.toLowerCase()) >= 0;
});
render('Matching assemblies', hits, 'No assembly matches that name.');
menu.classList.add('gsOpen');
return;
}
if (cache[t]) {
render('Matching assemblies', cache[t], 'No assembly matches that name.');
menu.classList.add('gsOpen');
return;
}
$.getJSON(searchUrl + encodeURIComponent(t))
.done(function(res) {
var list = reply(res, t);
cache[t] = list;
if (input.value.trim() === t) { // ignore replies for a term already typed past
render('Matching assemblies', list, 'No assembly matches that name.');
menu.classList.add('gsOpen');
}
})
.fail(function(jqXHR, textStatus, errorThrown) {
var list = (typeof config.onError === 'function') ?
config.onError(jqXHR, textStatus, errorThrown, t) : null;
render('Matching assemblies', list || [], 'No assembly matches that name.');
menu.classList.add('gsOpen');
});
}
// ---- selection --------------------------------------------------------------------
function choose(item) {
if (!item || item.disabled || !(item.genome || item.db)) { return; }
committed = item;
input.value = item.label || item.genome || item.db;
userTyped = false;
if (labelElement) { labelElement.innerHTML = item.label || ''; }
if (status) { status.textContent = 'Selected: ' + input.value; }
menu.classList.remove('gsOpen');
field.classList.remove('gsFocus');
// Keep the recent-genomes list working exactly as it does for the old widget.
if (typeof addRecentGenome === 'function' && item.db) { addRecentGenome(item); }
renderPicks();
if (typeof config.onSelect === 'function') { config.onSelect(item, labelElement); }
}
function renderPicks() {
var n = config.quickPickCount || 5;
var popular = (typeof getPopularGenomes === 'function') ? getPopularGenomes($(input)) : [];
var list = popular.slice(0, n);
// Lead with the most recent assembly that is not already one of the popular pills.
// Two things fall out of picking it that way rather than just taking recent[0]:
// - no duplicate pill. In the dropdown, seeing a genome under both Recent and Popular
// is useful; two identical pills side by side just reads as a bug.
// - the lead pill stops flickering. Choosing a popular assembly moves it to the head
// of the recents, which under recent[0] wiped out the lead pill that was there;
// since that assembly already has its own pill, the first non-popular recent - and
// so the pill - is unchanged. A GenArk assembly stays reachable in one click while
// the user flips between hg38 and hg19.
// Prepending rather than reordering keeps the popular pills in their familiar places.
var recent = (typeof getRecentGenomes === 'function') ? getRecentGenomes() : [];
var inList = function(g) {
return list.some(function(p) { return (p.db || p.genome) === (g.db || g.genome); });
};
var top = null;
for (var ri = 0; ri < recent.length; ri++) {
if (!inList(recent[ri])) { top = recent[ri]; break; }
}
if (top) { list = [top].concat(list); }
if (!list.length) { picks.innerHTML = ''; return; }
- picks.innerHTML = 'Quick picks:';
+ picks.innerHTML = 'Shortcuts:';
list.forEach(function(d) {
var b = document.createElement('button');
b.type = 'button';
// The pill shows the short db name; the full description is the tooltip, which is
// what keeps the row to one line.
b.className = 'gsPick' + (committed && committed.db === d.db ? ' gsPickOn' : '');
b.textContent = d.db || d.genome;
b.title = (top && d === top ? 'Most recently used: ' : '') + (d.label || '');
b.addEventListener('click', function() { choose(d); });
picks.appendChild(b);
});
}
function setActive(i) {
if (!rows.length) { return; }
active = Math.max(0, Math.min(i, rows.length - 1));
Array.prototype.forEach.call(menu.querySelectorAll('.gsRow'), function(el, j) {
el.classList.toggle('gsActive', j === active);
});
var el = menu.querySelector('.gsRow.gsActive');
if (el && el.scrollIntoView) { el.scrollIntoView({ block: 'nearest' }); }
}
// ---- events -------------------------------------------------------------------------
input.addEventListener('focus', function() {
field.classList.add('gsFocus');
// Select what is there so typing replaces the assembly name instead of appending to it.
// Deferred, because the click that delivers focus would otherwise collapse the selection.
setTimeout(function() { input.select(); }, 0);
// An empty field, or one still showing the label we put there, means "show me the common
// list"; anything else is something the user typed, so treat it as a query.
if (!input.value.trim() || !userTyped) {
openCommon();
} else {
search(input.value);
}
});
input.addEventListener('input', function() {
committed = null;
userTyped = true;
clearTimeout(timer);
var v = input.value;
timer = setTimeout(function() { search(v); }, 300);
});
input.addEventListener('keydown', function(ev) {
if (ev.key === 'ArrowDown') { ev.preventDefault(); menu.classList.add('gsOpen'); setActive(active + 1); }
else if (ev.key === 'ArrowUp') { ev.preventDefault(); setActive(active - 1); }
else if (ev.key === 'Enter') {
if (menu.classList.contains('gsOpen') && rows[active]) {
ev.preventDefault(); // do not submit the form on the same keystroke
choose(rows[active].item);
}
}
else if (ev.key === 'Escape') { menu.classList.remove('gsOpen'); }
});
caret.addEventListener('click', function(ev) {
ev.preventDefault();
if (menu.classList.contains('gsOpen')) {
menu.classList.remove('gsOpen');
} else {
input.focus();
openCommon();
}
});
menu.addEventListener('mousedown', function(ev) {
// mousedown, not click: the input's blur would otherwise close the menu first.
var row = ev.target.closest ? ev.target.closest('.gsRow') : null;
if (!row) { return; }
ev.preventDefault();
var r = rows[parseInt(row.getAttribute('data-i'), 10)];
if (r) { choose(r.item); }
});
menu.addEventListener('mouseover', function(ev) {
var row = ev.target.closest ? ev.target.closest('.gsRow') : null;
if (row) { setActive(parseInt(row.getAttribute('data-i'), 10)); }
});
document.addEventListener('click', function(ev) {
if (!wrap.contains(ev.target)) {
menu.classList.remove('gsOpen');
field.classList.remove('gsFocus');
}
});
renderPicks();
});
}