f93b8662afd701763c24634879d05dc08b3178de max Fri Jun 5 02:24:16 2026 -0700 Add exon search: jump to GENE exon N from position box I'm comitting this thinking that the way that we implement searches leads to duplication of code that doesn't look great to me. While this feature looks good, the code duplication across C/JS should probably get reduced with a different approach to the "quick jump" way of the page. We have currently three ways to quick jump, I think: - chr:start-end - rsxxxxx - gene symbol + autosuggest pick - HGVS? They are recognized by both the javascript and the C code with regexes. I think all of these should be probably be only implemented in the C code. The JS only sends the current string to the C code and then gets back if this can be autocompleted and to which position and what to show in the autosuggest area. For example if you type "SOD1e" the C code could send back "Continue typing to jump to exon" and once you're at "SOD1exon 5" the C code sends back "Hit enter to jump to chrX:123123-123213". This would work with any type of identifier and the code would stay in the C code, not more duplication and it would be much clearer to the user what is recognized in the search box. Users can now type "TP53 exon 5" or "TP53:e.5[+/-offset]" in the genome browser position/search box to navigate directly to that exon. The ":e.N" notation follows the VICC Gene Fusion Specification. An optional intronic offset (":e.5+2") lands N bases past the exon boundary, useful for splice site inspection. C (hgFind.c): findGeneExon() resolves the query against the SQL genePred tables listed in the hg.conf "geneTracks" key (default: mane, ncbiRefSeqSelect, knownGene, ncbiRefSeq, ncbiRefSeqHistorical). bigGenePred tracks (e.g. mane) are supported via bigBedOpenExtraIndex. Uses the existing exonToPos() function for strand-aware exon lookup. fixSinglePos() is called so hgp->singlePos is populated for callers. hgApi.c: new cmd=geneExonToPos returns {"pos":"chrom:start-end"} JSON so JS can navigate in place without a full page redirect to hgSearch. Direct URL links (hgTracks?position=GENE+exon+N) also work because findGeneExon() is hooked into hgPositionsFind(). JS: autocomplete.js injects a local "Jump to exon N" suggestion as soon as the exon pattern is detected, or a hint item when the query is still partial ("GENE ex"). Selecting either navigates via hgApi. hgTracks.js routes the two new autocomplete item types to the hgApi call. utils.js adds the two regexes (geneExonExp, geneExonCoordExp). query.html: documents both syntaxes; the :e.N notation links to the VICC Gene Fusion Specification at fusions.cancervariants.org. diff --git src/hg/js/hgTracks.js src/hg/js/hgTracks.js index 19d6115ad8c..3734aaa7c89 100644 --- src/hg/js/hgTracks.js +++ src/hg/js/hgTracks.js @@ -1,7456 +1,7502 @@ // hgTracks.js - Javascript for use in hgTracks CGI // Copyright (C) 2008 The Regents of the University of California // "use strict"; // Don't complain about line break before '||' etc: /* jshint -W014 */ /* jshint esversion: 8 */ var debug = false; /* Data passed in from CGI via the hgTracks object: * * string cgiVersion // CGI_VERSION * string chromName // current chromosome * int winStart // genomic start coordinate (0-based, half-open) * int winEnd // genomic end coordinate * int newWinWidth // new width (in bps) if user clicks on the top ruler * boolean revCmplDisp // true if we are in reverse display * int insideX // width of side-bar (in pixels) * int rulerClickHeight // height of ruler (in pixels) - zero if ruler is hidden * boolean inPlaceUpdate // true if in-place-update is turned on * int imgBox* // various drag-scroll values * boolean measureTiming // true if measureTiming is on * Object trackDb // hash of trackDb entries for tracks which are visible on current page * string highlight // highlight string, in format chrom#start#end#color|chrom2#start2#end2#color2|... * string prevHlColor // the last highlight color that the user picked */ /* IE11 compatibility - IE doesn't have string startsWith and never will */ if (!String.prototype.startsWith) { String.prototype.startsWith = function(searchString, position) { position = position || 0; return this.indexOf(searchString, position) === position; }; } function initVars() { // There are various entry points, so we call initVars in several places to make sure all is well if (typeof(hgTracks) !== "undefined" && !genomePos.original) { // remember initial position and size so we can restore it if user cancels genomePos.original = genomePos.getOriginalPos(); genomePos.originalSize = $('#size').text().replace(/,/g, ""); // strip out any commas dragSelect.originalCursor = jQuery('body').css('cursor'); if (typeof (igv) !== "undefined") { igv.initIgvUcsc(); } imageV2.imgTbl = $('#imgTbl'); // imageV2.enabled === true unless: advancedJavascript===false, or trackSearch, or config pg imageV2.enabled = (imageV2.imgTbl && imageV2.imgTbl.length > 0); // jQuery load function with stuff to support drag selection in track img if (theClient.isSafari()) { // Safari has the following bug: if we update the hgTracks map dynamically, // the browser ignores the changes (even though if you look in the DOM the changes // are there). So we have to do a full form submission when the user changes // visibility settings or track configuration. // As of 5.0.4 (7533.20.27) this is problem still exists in safari. // As of 5.1 (7534.50) this problem appears to have been fixed - unfortunately, // logs for 7/2011 show vast majority of safari users are pre-5.1 (5.0.5 is by far // the most common). // // Early versions of Chrome had this problem too, but this problem went away // as of Chrome 5.0.335.1 (or possibly earlier). // // KRR/JAT 2/2016: // This Safari issue is likely resolved in all current versions. However the test // for version had been failing, likely for some time now. // (As of 9.0.9, possibly earlier, the 3rd part of the version is included in the // user agent string, so must be accounted for in string match) // Consequences were that page refresh was used instead of img update (e.g. // for drag-zoom). And UI dialog was unable to update (e.g. via Apply button). imageV2.mapIsUpdateable = false; var reg = new RegExp("Version\/([0-9]+.[0-9]+)(.[0-9]+)? Safari"); var a = reg.exec(navigator.userAgent); if (a && a[1]) { var version = Number(a[1]); if (version >= 5.1) { imageV2.mapIsUpdateable = true; } } } imageV2.inPlaceUpdate = hgTracks.inPlaceUpdate && imageV2.mapIsUpdateable; } } ///////////////////////////////////// ////////// Genomic position ///////// ///////////////////////////////////// var genomePos = { original: null, originalSize: 0, linkFixup: function (pos, id, reg, endParamName) { // fixup external links (e.g. ensembl) var ele = $(document.getElementById(id)); if (ele.length) { var link = ele.attr('href'); var a = reg.exec(link); if (a && a[1]) { ele.attr('href', a[1] + pos.start + "&" + endParamName + "=" + pos.end); } } }, setByCoordinates: function (chrom, start, end) { var newPosition = chrom + ":" + start + "-" + end; genomePos.set(newPosition, end - start + 1); return newPosition; }, getElement: function () { // Return position box object var tags = document.getElementsByName("position"); // There are multiple tags with name === "position" (the visible position text input // and a hidden with id='positionHidden'); we return value of visible element. for (var i = 0; i < tags.length; i++) { var ele = tags[i]; if (ele.id !== "positionHidden") { return ele; } } return null; }, get: function () { // Return current value of position box var ele = genomePos.getElement(); if (ele) { return ele.value; } return null; }, getOriginalPos: function () { return genomePos.original || genomePos.get(); }, revertToOriginalPos: function () { // undo changes to position (i.e. after user aborts a drag-and-select). this.set(this.original, this.originalSize); }, undisguisePosition: function(position) // UN-DISGUISE VMODE { // find the virt position // position should be real chrom span var pos = parsePosition(position); if (!pos) return position; // some parsing error, return original var start = pos.start - 1; var end = pos.end; var chromName = hgTracks.windows[0].chromName; if (pos.chrom !== chromName) return position; // return original var newStart = -1; var newEnd = -1; var lastW = null; var windows = null; for (j=0; j < 3; ++j) { if (j === 0) windows = hgTracks.windowsBefore; if (j === 1) windows = hgTracks.windows; if (j === 2) windows = hgTracks.windowsAfter; for (i=0,len=windows.length; i < len; ++i) { var w = windows[i]; // double check chrom is same thoughout all windows, otherwise warning, return original value if (w.chromName != chromName) { return position; // return original } // check that the regions are ascending and non-overlapping if (lastW && w.winStart < lastW.winEnd) { return position; // return original } // overlap with position? // if intersection, if (w.winEnd > start && end > w.winStart) { var s = Math.max(start, w.winStart); var e = Math.min(end, w.winEnd); var cs = s - w.winStart + w.virtStart; var ce = e - w.winStart + w.virtStart; if (newStart === -1) newStart = cs; newEnd = ce; } lastW = w; } } // return new virt undisguised position as a string var newPos = "multi:" + (newStart+1) + "-" + newEnd; return newPos; }, disguiseSize: function(position) // DISGUISE VMODE { // find the real size of the windows spanned // position should be a real chrom span var pos = parsePosition(position); if (!pos) return 0; var start = pos.start - 1; var end = pos.end; var newSize = 0; var windows = null; for (j=0; j < 3; ++j) { if (j === 0) windows = hgTracks.windowsBefore; if (j === 1) windows = hgTracks.windows; if (j === 2) windows = hgTracks.windowsAfter; for (i=0,len=windows.length; i < len; ++i) { var w = windows[i]; // overlap with position? // if intersection, if (w.winEnd > start && end > w.winStart) { var s = Math.max(start, w.winStart); var e = Math.min(end, w.winEnd); newSize += (e - s); } } } // return real size of the disguised position return newSize; }, disguisePosition: function(position) // DISGUISE VMODE { // find the single-chrom range spanned // position should be virt var pos = parsePosition(position); if (!pos) return position; // some parsing error, return original var start = pos.start - 1; var end = pos.end; var chromName = hgTracks.windows[0].chromName; var newStart = -1; var newEnd = -1; var lastW = null; var windows = null; for (j=0; j < 3; ++j) { if (j === 0) windows = hgTracks.windowsBefore; if (j === 1) windows = hgTracks.windows; if (j === 2) windows = hgTracks.windowsAfter; for (i=0,len=windows.length; i < len; ++i) { var w = windows[i]; // double check chrom is same thoughout all windows, otherwise warning, return original value if (w.chromName != chromName) { return position; // return undisguised original } // check that the regions are ascending and non-overlapping if (lastW && w.winStart < lastW.winEnd) { return position; // return undisguised original } // overlap with position? // if intersection, if (w.virtEnd > start && end > w.virtStart) { var s = Math.max(start, w.virtStart); var e = Math.min(end, w.virtEnd); var cs = s - w.virtStart + w.winStart; var ce = e - w.virtStart + w.winStart; if (newStart === -1) newStart = cs; newEnd = ce; } lastW = w; } } // return new non-virt disguised position as a string var newPos = chromName + ":" + (newStart+1) + "-" + newEnd; return newPos; }, set: function (position, size) { // Set value of position and size (in hiddens and input elements). // We assume size has already been commified. // Either position or size may be null. // stack dump // DEBUG //console.trace(); // NOT work on safari //var obj = {}; //Error.captureStackTrace(obj); //warn("genomePos.set() called "+obj.stack); position = position.replace(/,/g, ""); // strip out any commas position.replace("virt:", "multi:"); if (position) { // DISGUISE VMODE //warn("genomePos.set() called, position = "+position); if (hgTracks.virtualSingleChrom && (position.search("multi:")===0)) { var newPosition = genomePos.disguisePosition(position); //warn("genomePos.set() position = "+position+", newPosition = "+newPosition); position = newPosition; } } if (position) { // There are multiple tags with name === "position" // (one in TrackHeaderForm and another in TrackForm). var tags = document.getElementsByName("position"); for (var i = 0; i < tags.length; i++) { var ele = tags[i]; ele.value = position; } } var pos = parsePosition(position); if ($('#positionDisplay').length) { // add commas to positionDisplay var commaPosition = position; if (pos) commaPosition = pos.chrom+":"+commify(pos.start)+"-"+commify(pos.end); $('#positionDisplay').text(commaPosition); } if (size) { if (hgTracks.virtualSingleChrom && (position.search("multi:")!==0)) { var newSize = genomePos.disguiseSize(position); //warn("genomePos.set() position = "+position+", newSize = "+newSize); if (newSize > 0) size = newSize; } $('#size').text(commify(size)); // add commas } if (pos) { // fixup external static links on page' // Example ensembl link: // http://www.ensembl.org/Homo_sapiens/contigview?chr=21&start=33031934&end=33041241 genomePos.linkFixup(pos, "ensemblLink", new RegExp("(.+start=)[0-9]+"), "end"); // Example NCBI Map Viewer link (obsolete): // https://www.ncbi.nlm.nih.gov/mapview/maps.cgi?taxid=9606&CHR=21&BEG=33031934&END=33041241 genomePos.linkFixup(pos, "ncbiLink", new RegExp("(.+BEG=)[0-9]+"), "END"); // Example NCBI Genome Data Viewer link // https://www.ncbi.nlm.nih.gov/genome/gdv/browser/?id=GCF_000001405.37&chr=4&from=45985744&to=45991655&context=genome genomePos.linkFixup(pos, "ncbiLink", new RegExp("(.+from=)[0-9]+"), "to"); // Example medaka link: // http://utgenome.org/medakabrowser_ens_jump.php?revision=version1.0&chr=chromosome18&start=14435198&end=14444829 genomePos.linkFixup(pos, "medakaLink", new RegExp("(.+start=)[0-9]+"), "end"); var link; var reg; var a; if ($('#wormbaseLink').length) { // e.g. http://www.wormbase.org/db/gb2/gbrowse/c_elegans?name=II:14646301-14667800 link = $('#wormbaseLink').attr('href'); reg = new RegExp("(.+:)[0-9]+"); a = reg.exec(link); if (a && a[1]) { $('#wormbaseLink').attr('href', a[1] + pos.start + "-" + pos.end); } } // Fixup DNA link; e.g.: // hgc?hgsid=2999470&o=114385768&g=getDna&i=mixed&c=chr7&l=114385768&r=114651696&db=panTro2&hgsid=2999470 if ($('#dnaLink').length) { link = $('#dnaLink').attr('href'); reg = new RegExp("(.+&o=)[0-9]+.+&db=[^&]+(.*)"); a = reg.exec(link); if (a && a[1]) { var url = a[1] + (pos.start - 1) + "&g=getDna&i=mixed&c=" + pos.chrom; url += "&l=" + (pos.start - 1) + "&r=" + pos.end + "&db=" + getDb() + a[2]; $('#dnaLink').attr('href', url); } } } if (!imageV2.backSupport) imageV2.markAsDirtyPage(); }, getXLimits : function(img, slop) { // calculate the min/max x position for drag-select, such that user cannot drag into the label area var imgWidth = jQuery(img).width(); var imgOfs = jQuery(img).offset(); var leftX = hgTracks.revCmplDisp ? imgOfs.left - slop : imgOfs.left + hgTracks.insideX - slop; var rightX = hgTracks.revCmplDisp ? imgOfs.left + imgWidth - hgTracks.insideX + slop : imgOfs.left + imgWidth + slop; return [leftX, rightX]; }, check: function (img, selection) { // return true if user's selection is still w/n the img (including some slop). var imgWidth = jQuery(img).width(); var imgHeight = jQuery(img).height(); var imgOfs = jQuery(img).offset(); var slop = 10; // No need to check the x limits anymore, as imgAreaSelect is doing that now. return ( (selection.event.pageY >= (imgOfs.top - slop)) && (selection.event.pageY < (imgOfs.top + imgHeight + slop))); }, pixelsToBases: function (img, selStart, selEnd, winStart, winEnd, addHalfBp) { // Convert image coordinates to chromosome coordinates var imgWidth = jQuery(img).width() - hgTracks.insideX; var width = hgTracks.winEnd - hgTracks.winStart; var mult = width / imgWidth; // mult is bp/pixel multiplier // where does a bp position start on the screen? // For things like drag-select, if the user ends just before the nucleotide itself, do not count // the nucleotide itself as selected. But for things like clicks onto // a selection, if the user right-clicks just before the middle of the // nucleotide, we certainly want to use this position. var halfBpWidth = 0; if (addHalfBp) halfBpWidth = (imgWidth / width) / 2; // how many pixels does one bp take up; var startDelta; // startDelta is how many bp's to the right/left var x1; // The magic number three appear at another place in the code // as LEFTADD. It was originally annotated as "borders or cgi item calc // ?" by Larry. It has to be used when going any time when converting // between pixels and coordinates. selStart -= 3; selEnd -= 3; if (hgTracks.revCmplDisp) { x1 = Math.min(imgWidth, selStart); startDelta = Math.floor(mult * (imgWidth - x1 - halfBpWidth)); } else { x1 = Math.max(hgTracks.insideX, selStart); startDelta = Math.floor(mult * (x1 - hgTracks.insideX + halfBpWidth)); } var endDelta; var x2; if (hgTracks.revCmplDisp) { endDelta = startDelta; x2 = Math.min(imgWidth, selEnd); startDelta = Math.floor(mult * (imgWidth - x2 + halfBpWidth)); } else { x2 = Math.max(hgTracks.insideX, selEnd); endDelta = Math.floor(mult * (x2 - hgTracks.insideX - halfBpWidth)); } var newStart = hgTracks.winStart + startDelta; var newEnd = hgTracks.winStart + 1 + endDelta; // if user selects space between two bases, start>end can happen if (newStart >= newEnd) newStart = newEnd-1; if (newEnd > winEnd) { newEnd = winEnd; } return {chromStart : newStart, chromEnd : newEnd}; }, chromToVirtChrom: function (chrom, chromStart, chromEnd) { // Convert regular chromosome position to virtual chrom coordinates using hgTracks.windows list // Consider the first contiguous set of overlapping regions to define the match (for now). // only works for regions covered by the current hgTracks.windows var virtStart = -1, virtEnd = -1; var s,e; var i, len; for (i = 0, len = hgTracks.windows.length; i < len; ++i) { var w = hgTracks.windows[i]; var overlap = (chrom == w.chromName && chromEnd > w.winStart && w.winEnd > chromStart); if (virtStart == -1) { if (overlap) { // when they overlap the first time s = Math.max(chromStart, w.winStart); e = Math.min(chromEnd, w.winEnd); virtStart = w.virtStart + (s - w.winStart); virtEnd = w.virtStart + (e - w.winStart); } else { // until they overlap // do nothing } } else { if (overlap) { // while they continue to overlap, extend e = Math.min(chromEnd, w.winEnd); virtEnd = w.virtStart + (e - w.winStart); } else { // when they do not overlap anymore, stop break; } } } return {chromStart : virtStart, chromEnd : virtEnd}; }, selectionPixelsToBases: function (img, selection) { // Convert selection x1/x2 coordinates to chromStart/chromEnd. return genomePos.pixelsToBases(img, selection.x1, selection.x2, hgTracks.winStart, hgTracks.winEnd, true); }, update: function (img, selection, singleClick) { var pos = genomePos.pixelsToBases(img, selection.x1, selection.x2, hgTracks.winStart, hgTracks.winEnd, true); // singleClick is true when the mouse hasn't moved (or has only moved a small amount). if (singleClick) { var center = (pos.chromStart + pos.chromEnd)/2; pos.chromStart = Math.floor(center - hgTracks.newWinWidth/2); pos.chromEnd = pos.chromStart + hgTracks.newWinWidth; // clip if (pos.chromStart < hgTracks.chromStart) pos.chromStart = hgTracks.chromStart; // usually 1 if (pos.chromEnd > hgTracks.chromEnd) pos.chromEnd = hgTracks.chromEnd; // usually virt chrom size // save current position so that that it may be restored after highlight or cancel. genomePos.original = genomePos.getOriginalPos(); genomePos.originalSize = $('#size').text().replace(/,/g, ""); // strip out any commas } var newPosition = genomePos.setByCoordinates(hgTracks.chromName, pos.chromStart+1, pos.chromEnd); return newPosition; }, handleChange: function (response, status) { var json = JSON.parse(response); genomePos.set(json.pos); }, changeAssemblies: function (ele) // UNUSED? Larry's experimental code { // code to update page when user changes assembly select list. $.ajax({ type: "GET", url: "../cgi-bin/hgApi", data: cart.varsToUrlData({ 'hgsid': getHgsid(), 'cmd': 'defaultPos', 'db': getDb() }), dataType: "html", trueSuccess: genomePos.handleChange, success: catchErrorOrDispatch, error: errorHandler, cache: true }); return false; }, convertedVirtCoords : {chromStart : -1, chromEnd : -1}, handleConvertChromPosToVirtCoords: function (response, status) { var virtStart = -1, virtEnd = -1; var newJson = scrapeVariable(response, "convertChromToVirtChrom"); if (!newJson) { warn("convertChromToVirtChrom object is missing from the response"); } else { virtStart = newJson.virtWinStart; virtEnd = newJson.virtWinEnd; } genomePos.convertedVirtCoords = {chromStart : virtStart, chromEnd : virtEnd}; }, convertChromPosToVirtCoords: function (chrom, chromStart, chromEnd) { // code to convert chrom position to virt coords genomePos.convertedVirtCoords = {chromStart : -1, chromEnd : -1}; // reset var pos = chrom+":"+(chromStart+1)+"-"+chromEnd; // easier to pass 1 parameter than 3 $.ajax({ type: "GET", async: false, // wait for result url: "../cgi-bin/hgTracks", data: cart.varsToUrlData({ 'hgt.convertChromToVirtChrom': pos, 'hgt.trackImgOnly' : 1, 'hgsid': getHgsid(), 'db': getDb() }), dataType: "html", trueSuccess: genomePos.handleConvertChromPosToVirtCoords, success: catchErrorOrDispatch, error: errorHandler, cache: false }); return genomePos.convertedVirtCoords; }, positionDisplayDialog: function () // Show the virtual and real positions of the windows { var position = genomePos.get(); position.replace("virt:", "multi:"); var positionDialog = $("#positionDialog")[0]; if (!positionDialog) { $("body").append("
"); positionDialog = $("#positionDialog")[0]; } if (hgTracks.windows) { var i, len, end; var matches = /^multi:[0-9]+-([0-9]+)/.exec(position); var modeType = (hgTracks.virtModeType === "customUrl" ? "Custom regions on virtual chromosome" : (hgTracks.virtModeType === "exonMostly" ? "Exon view of" : (hgTracks.virtModeType === "geneMostly" ? "Gene view of" : (hgTracks.virtModeType === "singleAltHaplo" ? "Alternate haplotype as virtual chromosome" : "Unknown mode")))); var str = modeType + " " + position; if (matches) { end = matches[1]; if (end < hgTracks.chromEnd) { str += ". Full virtual region is multi:1-" + hgTracks.chromEnd + ". Zoom out to view."; } } if (!(hgTracks.virtualSingleChrom && (hgTracks.windows.length === 1))) { var w; if (hgTracks.windows.length <= 10) { str += "

\n"; for (i=0,len=hgTracks.windows.length; i < len; ++i) { w = hgTracks.windows[i]; str += "\n"; } str += "
" + w.chromName + ":" + (w.winStart+1) + "-" + w.winEnd + "" + (w.winEnd - w.winStart) + " bp" + "

\n"; } else { str += "
    \n"; for (i=0,len=hgTracks.windows.length; i < len; ++i) { w = hgTracks.windows[i]; str += "
  • " + w.chromName + ":" + (w.winStart+1) + "-" + w.winEnd + "   " + (w.winEnd - w.winStart) + " bp" + "
  • \n"; } str += "
\n"; } } $("#positionDisplayPosition").html(str); } else { $("#positionDisplayPosition").html(position); } $(positionDialog).dialog({ modal: true, title: "Multi-Region Position Ranges", closeOnEscape: true, resizable: false, autoOpen: false, minWidth: 400, minHeight: 40, close: function() { // All exits to dialog should go through this $(imageV2.imgTbl).imgAreaSelect({hide:true}); $(this).hide(); $('body').css('cursor', ''); // Occasionally wait cursor got left behind } }); $(positionDialog).dialog('open'); } }; ///////////////////////////////////// //// Creating items ///// ///////////////////////////////////// var myVariants = { createBedForm: function(dialogEle) { // Shared top fields (visible in all manual modes) const commonTopFields = [ { label: "Label", id: "name", type: "text", placeholder: "Optional item label", info: "A short label for this annotation, displayed in the browser" }, { label: "Color", id: "color", type: "text" }, ]; // SNV-only field (between commonTop and alt/sequence) const snvOnlyFields = [ { label: "Ref", id: "ref", type: "text", placeholder: "Optional reference allele sequence", info: "Reference allele sequence at this position" }, ]; // Alt/Sequence field: shown for SNV (label="Alt") and CNV (label="Sequence") const altField = { label: "Alt", id: "alt", type: "text", placeholder: "Optional alternate allele sequence", info: "Alternate (variant) allele sequence" }; // Position fields: shown always for Transcript; collapsible for SNV/CNV const positionFields = [ { label: "Chromosome", id: "chrom", type: "text" }, { label: "Start", id: "start", type: "number", info: "1-based start position on the chromosome" }, { label: "End", id: "end", type: "number", info: "1-based end position on the chromosome (inclusive)" }, { label: "Score", id: "score", type: "number", info: "Score from 0-1000. Higher scores display darker" }, { label: "Strand", id: "strand", type: "text", info: "Strand: + for forward, - for reverse, . for unknown" }, ]; // Transcript-only CDS fields (displayed as CDS Start/End; stored as thickStart/thickEnd) const cdsFields = [ { label: "CDS Start", id: "thickStart", type: "number", info: "Start of the coding region" }, { label: "CDS End", id: "thickEnd", type: "number", info: "End of the coding region" }, ]; // Shared bottom fields const commonBottomFields = [ { label: "Mouseover", id: "mouseover", type: "text", placeholder: "Short text shown on hover", info: "Short text shown when hovering over this item. If empty, the label and alleles are displayed" }, { label: "Description", id: "description", type: "textarea", placeholder: "Longer description/notes", info: "Longer notes or comments about this annotation. Displayed on the details page" }, { label: "Project", id: "project", type: "text", placeholder: "Optional project name", info: "Group annotations by project. Projects with no annotations are automatically removed from the list" }, ]; // CNV vocabulary, mirrored on the server in myVariantsTrack.c:validCnvTypes const cnvTypeOptions = ["deletion", "duplication", "insertion", "inversion", "translocation", "complex", "breakend"]; const form = document.createElement("form"); form.className = "myVariants-form"; form.action = "hgTracks"; form.method = "post"; form.id = "myVariants-form"; // First: HGVS/position input box (primary interaction method) let quickInpDiv = document.createElement("div"); quickInpDiv.id = "hgvsInputDiv"; form.appendChild(quickInpDiv); let quickInp = document.createElement("input"); let quickInpLabel = document.createElement("label"); quickInp.type = "text"; quickInp.id = "hgvsInput"; quickInp.name = "myVariantsHgvsInput"; quickInp.style.width = "300px"; quickInpLabel.textContent = "Enter HGVS, position, or gene symbol"; quickInpLabel.style.display = "inline-block"; quickInpLabel.style.minWidth = "200px"; quickInpLabel.style.marginRight = "8px"; quickInpLabel.for = "hgvsInput"; let resizeDialog = function() { var dialog = $("#myVariantsDialog"); var hgvsVisible = document.getElementById("hgvsInputDiv").style.display !== "none"; if (hgvsVisible) { dialog.dialog("option", "width", 580); dialog.dialog("option", "height", "auto"); dialog.dialog("option", "position", { my: "center", at: "center", of: window }); } else { dialog.dialog("option", "width", Math.min(1000, window.innerWidth * 0.9)); dialog.dialog("option", "height", Math.min(800, window.innerHeight * 0.9)); dialog.dialog("option", "position", { my: "top+30", at: "top", of: window }); } }; let updatePositionSummary = function() { let summaryText = document.getElementById("positionSummaryText"); if (!summaryText) return; let chrom = document.getElementById("chrom"); let start = document.getElementById("start"); let end = document.getElementById("end"); if (chrom && start && end) { let startVal = parseInt(start.value); let endVal = parseInt(end.value); let startFmt = isNaN(startVal) ? start.value : startVal.toLocaleString(); let endFmt = isNaN(endVal) ? end.value : endVal.toLocaleString(); summaryText.textContent = chrom.value + ":" + startFmt + "-" + endFmt; } }; // Switch the form into one of four modes and remember the choice so the // next dialog open restarts in the same place. let applyMode = function(mode) { const isHgvs = mode === "hgvs"; const isTranscript = mode === "transcript"; const isSnv = mode === "snv"; const isCnv = mode === "cnv"; document.getElementById("hgvsInputDiv").style.display = isHgvs ? "" : "none"; document.getElementById("manualInputDiv").style.display = isHgvs ? "none" : ""; document.getElementById("hgvsManualToggle").textContent = isHgvs ? "Or edit item fields manually" : "Back to quick input mode"; if (!isHgvs) { document.getElementById("typeTranscript").checked = isTranscript; document.getElementById("typeSnv").checked = isSnv; document.getElementById("typeCnv").checked = isCnv; } document.getElementById("refWrapper").style.display = isSnv ? "" : "none"; document.getElementById("altWrapper").style.display = (isSnv || isCnv) ? "" : "none"; document.getElementById("altLabel").textContent = isCnv ? "Sequence" : "Alt"; document.getElementById("cnvTypeWrapper").style.display = isCnv ? "" : "none"; if (isTranscript) { advancedDiv.style.display = "none"; cdsBlocksDiv.style.display = "none"; posWraps.forEach(function(w) { w.style.display = ""; }); cdsWraps.forEach(function(w) { w.style.display = ""; }); blocksSection.style.display = ""; const order = [ typeRadioBar, posSummary, posWraps[0], posWraps[1], posWraps[2], nameWrap, posWraps[3], posWraps[4], cdsWraps[0], cdsWraps[1], colorWrap, mouseoverWrap, descriptionWrap, projectWrap, blocksSection, advancedDiv, cdsBlocksDiv, refWrapper, cnvTypeWrapper, altWrapper, customFieldsSection ]; order.forEach(function(el) { manualInpDiv.appendChild(el); }); } else { // Pack wrappers back into their collapsible containers. posWraps.forEach(function(w) { advancedDiv.appendChild(w); }); cdsWraps.forEach(function(w) { cdsBlocksDiv.appendChild(w); }); cdsBlocksDiv.appendChild(blocksSection); advancedDiv.style.display = "none"; cdsBlocksDiv.style.display = "none"; const order = [ typeRadioBar, posSummary, advancedDiv, nameWrap, colorWrap, refWrapper, cnvTypeWrapper, altWrapper, cdsBlocksDiv, mouseoverWrap, descriptionWrap, projectWrap, customFieldsSection ]; order.forEach(function(el) { manualInpDiv.appendChild(el); }); } document.getElementById("positionSummaryDiv").style.display = isTranscript ? "none" : ""; if (posEditLink) posEditLink.textContent = "[edit]"; try { localStorage.setItem("myVariantsLastMode", mode); } catch (e) { /* private mode */ } resizeDialog(); }; let currentType = function() { if (document.getElementById("typeTranscript").checked) return "transcript"; if (document.getElementById("typeCnv").checked) return "cnv"; return "snv"; }; let toggleForm = function(event) { event.preventDefault(); const inHgvs = document.getElementById("hgvsInputDiv").style.display !== "none"; applyMode(inHgvs ? currentType() : "hgvs"); }; let toggleContainer = document.createElement("p"); let toggle = document.createElement("a"); toggle.href = "#"; toggle.id = "hgvsManualToggle"; toggle.addEventListener("click", toggleForm); toggle.textContent = "Or edit item fields manually"; toggleContainer.appendChild(toggle); quickInpDiv.appendChild(quickInpLabel); quickInpDiv.appendChild(quickInp); form.appendChild(toggleContainer); // Manual input div (contains simple + advanced fields) let manualInpDiv = document.createElement("div"); manualInpDiv.id = "manualInputDiv"; manualInpDiv.style.display = "none"; let posSummary = document.createElement("div"); posSummary.id = "positionSummaryDiv"; posSummary.style.cssText = "margin-bottom:12px; padding:6px 10px; background:#f0f4f8; border:1px solid #d0d7de; border-radius:4px; font-size:13px;"; let posSummaryLabel = document.createElement("b"); posSummaryLabel.textContent = "Position: "; posSummary.appendChild(posSummaryLabel); let posSummaryText = document.createElement("span"); posSummaryText.id = "positionSummaryText"; posSummary.appendChild(posSummaryText); let posSummarySuffix = document.createElement("span"); posSummarySuffix.style.color = "#888"; posSummarySuffix.textContent = " (from current view)"; posSummary.appendChild(posSummarySuffix); let posEditLink = document.createElement("a"); posEditLink.href = "#"; posEditLink.textContent = "[edit]"; posEditLink.style.cssText = "margin-left:8px; font-size:12px;"; posEditLink.addEventListener("click", function(event) { event.preventDefault(); let advDiv = document.getElementById("advancedFieldsDiv"); if (!advDiv) return; if (advDiv.style.display === "none") { advDiv.style.display = ""; posEditLink.textContent = "[hide]"; let chromField = document.getElementById("chrom"); if (chromField) { chromField.scrollIntoView({behavior: "smooth", block: "nearest"}); chromField.focus(); } } else { advDiv.style.display = "none"; posEditLink.textContent = "[edit]"; } }); posSummary.appendChild(posEditLink); manualInpDiv.appendChild(posSummary); // Helper function to create form field (uses createInfoIcon from utils.js) let createField = function(field, container) { const wrapper = document.createElement("div"); wrapper.style.marginBottom = "8px"; const label = document.createElement("label"); label.htmlFor = field.id; label.textContent = field.label; label.style.display = "inline-block"; label.style.minWidth = "140px"; let input; if (field.type === "textarea") { input = document.createElement("textarea"); input.rows = "4"; input.cols = "60"; } else { input = document.createElement("input"); input.type = field.type; } input.id = field.id; if (field.id === "chrom") { input.value = hgTracks.chromName; } if (field.type === "number") { input.min = 0; if (field.id === "start" || field.id === "thickStart") { input.value = hgTracks.winStart; } if (field.id === "end" || field.id === "thickEnd") { input.value = hgTracks.winEnd; } if (field.id === "score") { input.value = 0; input.max = 1000; } } if (field.id === "strand") { input.value = "."; } if (field.id === "color") { input.value = "#000000"; input.style.width = "70px"; } if (field.placeholder) { input.placeholder = field.placeholder; } wrapper.appendChild(label); wrapper.appendChild(input); if (field.info) { wrapper.appendChild(createInfoIcon(field.info)); } container.appendChild(wrapper); return input; }; // Helper function to create project field with dropdown if projects exist let createProjectField = function(container) { const wrapper = document.createElement("div"); wrapper.style.marginBottom = "8px"; const label = document.createElement("label"); label.htmlFor = "project"; label.textContent = "Project"; label.style.display = "inline-block"; label.style.minWidth = "140px"; wrapper.appendChild(label); // Check if we have existing projects from the server let existingProjects = (typeof hgTracks !== 'undefined' && hgTracks.myVariantsProjects) ? hgTracks.myVariantsProjects : []; if (existingProjects.length > 0) { // Create dropdown with existing projects let select = document.createElement("select"); select.id = "projectSelect"; select.style.marginRight = "8px"; // Add empty option let emptyOpt = document.createElement("option"); emptyOpt.value = ""; emptyOpt.textContent = "(none)"; select.appendChild(emptyOpt); // Add existing projects existingProjects.forEach(proj => { let opt = document.createElement("option"); opt.value = proj; opt.textContent = proj; select.appendChild(opt); }); // Add "Add new..." option let newOpt = document.createElement("option"); newOpt.value = "__new__"; newOpt.textContent = "Add new..."; select.appendChild(newOpt); wrapper.appendChild(select); // Hidden text input for new project (shown when "Add new..." selected) let newProjectInput = document.createElement("input"); newProjectInput.type = "text"; newProjectInput.id = "project"; newProjectInput.placeholder = "Enter new project name"; newProjectInput.style.display = "none"; wrapper.appendChild(newProjectInput); // Toggle between dropdown and text input select.addEventListener("change", function() { if (select.value === "__new__") { newProjectInput.style.display = ""; newProjectInput.focus(); } else { newProjectInput.style.display = "none"; newProjectInput.value = select.value; } }); // Keep text input synced with dropdown selection newProjectInput.addEventListener("blur", function() { if (newProjectInput.value === "" && select.value === "__new__") { select.value = ""; newProjectInput.style.display = "none"; } }); } else { // No existing projects - just show text input let input = document.createElement("input"); input.type = "text"; input.id = "project"; input.placeholder = "Optional project name"; wrapper.appendChild(input); } // Add info icon wrapper.appendChild(createInfoIcon("Group annotations by project. Projects with no annotations are automatically removed from the list")); container.appendChild(wrapper); }; // Type radio bar: Transcript | Variant -> Short | CNV. Inserted at the very // top of manualInpDiv (before the existing position summary). let typeRadioBar = document.createElement("div"); typeRadioBar.id = "typeRadioBar"; typeRadioBar.style.cssText = "margin-bottom:12px; padding:8px 10px; background:#f7f7f7; border:1px solid #ddd; border-radius:4px;"; typeRadioBar.innerHTML = "
Annotation type
" + "
" + "" + "" + "" + "
"; manualInpDiv.insertBefore(typeRadioBar, manualInpDiv.firstChild); let onTypeRadioChange = function() { applyMode(currentType()); }; // The form is not yet in the document, so query the local subtree. typeRadioBar.querySelectorAll("input[type=radio]").forEach(function(r) { r.addEventListener("change", onTypeRadioChange); }); let colorInput = null; let nameWrap, colorWrap; commonTopFields.forEach(function(field) { let input = createField(field, manualInpDiv); if (field.id === "color") { colorInput = input; colorWrap = input.parentNode; } else if (field.id === "name") { nameWrap = input.parentNode; } }); // SNV-only ref input let refWrapper = document.createElement("div"); refWrapper.id = "refWrapper"; createField(snvOnlyFields[0], refWrapper); manualInpDiv.appendChild(refWrapper); // CNV-only type select let cnvTypeWrapper = document.createElement("div"); cnvTypeWrapper.id = "cnvTypeWrapper"; cnvTypeWrapper.style.marginBottom = "8px"; let cnvTypeLabel = document.createElement("label"); cnvTypeLabel.htmlFor = "cnvType"; cnvTypeLabel.textContent = "CNV type"; cnvTypeLabel.style.cssText = "display:inline-block; min-width:140px;"; let cnvTypeSelect = document.createElement("select"); cnvTypeSelect.id = "cnvType"; cnvTypeOptions.forEach(function(t) { let opt = document.createElement("option"); opt.value = t; opt.textContent = t; cnvTypeSelect.appendChild(opt); }); cnvTypeWrapper.appendChild(cnvTypeLabel); cnvTypeWrapper.appendChild(cnvTypeSelect); cnvTypeWrapper.appendChild(createInfoIcon("CNV vocabulary follows gnomAD")); manualInpDiv.appendChild(cnvTypeWrapper); // Alt / Sequence input (shared between SNV and CNV; label switches in applyMode) let altWrapper = document.createElement("div"); altWrapper.id = "altWrapper"; altWrapper.style.marginBottom = "8px"; let altLabel = document.createElement("label"); altLabel.htmlFor = "alt"; altLabel.id = "altLabel"; altLabel.textContent = "Alt"; altLabel.style.cssText = "display:inline-block; min-width:140px;"; let altInput = document.createElement("input"); altInput.type = "text"; altInput.id = "alt"; altInput.placeholder = altField.placeholder; altWrapper.appendChild(altLabel); altWrapper.appendChild(altInput); altWrapper.appendChild(createInfoIcon(altField.info)); manualInpDiv.appendChild(altWrapper); let advancedDiv = document.createElement("div"); advancedDiv.id = "advancedFieldsDiv"; advancedDiv.style.display = "none"; let posWraps = positionFields.map(function(field) { return createField(field, advancedDiv).parentNode; }); manualInpDiv.appendChild(advancedDiv); let cdsBlocksDiv = document.createElement("div"); cdsBlocksDiv.id = "cdsBlocksDiv"; cdsBlocksDiv.style.display = "none"; let cdsWraps = cdsFields.map(function(field) { return createField(field, cdsBlocksDiv).parentNode; }); let blocksSection = document.createElement("div"); blocksSection.id = "blocksSection"; blocksSection.style.cssText = "margin-top:12px; padding-top:10px; border-top:1px solid #ddd;"; let blocksLabel = document.createElement("div"); blocksLabel.style.cssText = "font-weight:bold; margin-bottom:8px; font-size:13px;"; blocksLabel.textContent = "Blocks (optional)"; blocksSection.appendChild(blocksLabel); let blocksHint = document.createElement("div"); blocksHint.style.cssText = "font-size:12px; color:#666; margin-bottom:6px;"; blocksHint.textContent = "Offsets are relative to Start. First offset must be 0; " + "last block must reach End. Leave empty for a single full-span block."; blocksSection.appendChild(blocksHint); let blocksContainer = document.createElement("div"); blocksContainer.id = "blocksContainer"; blocksSection.appendChild(blocksContainer); let hCount = document.createElement("input"); hCount.type = "hidden"; hCount.id = "blockCount"; let hSizes = document.createElement("input"); hSizes.type = "hidden"; hSizes.id = "blockSizes"; let hStarts = document.createElement("input"); hStarts.type = "hidden"; hStarts.id = "chromStarts"; blocksSection.appendChild(hCount); blocksSection.appendChild(hSizes); blocksSection.appendChild(hStarts); cdsBlocksDiv.appendChild(blocksSection); manualInpDiv.appendChild(cdsBlocksDiv); let mouseoverWrap, descriptionWrap, projectWrap; commonBottomFields.forEach(function(field) { if (field.id === "project") { createProjectField(manualInpDiv); projectWrap = manualInpDiv.lastElementChild; } else { let input = createField(field, manualInpDiv); if (field.id === "mouseover") mouseoverWrap = input.parentNode; else if (field.id === "description") descriptionWrap = input.parentNode; } }); let customFieldsSection = document.createElement("div"); customFieldsSection.id = "customFieldsSection"; customFieldsSection.style.cssText = "margin-top:12px; padding-top:10px; border-top:1px solid #ddd;"; let customFieldsLabel = document.createElement("div"); customFieldsLabel.style.cssText = "font-weight:bold; margin-bottom:8px; font-size:13px;"; customFieldsLabel.textContent = "Custom Fields"; customFieldsSection.appendChild(customFieldsLabel); let customFieldsList = document.createElement("div"); customFieldsList.id = "customFieldsList"; customFieldsSection.appendChild(customFieldsList); let reservedNames = ["bin", "chrom", "chromStart", "chromEnd", "name", "score", "strand", "thickStart", "thickEnd", "itemRgb", "blockCount", "blockSizes", "chromStarts", "description", "db", "ref", "alt", "project", "mouseover", "itemType", "cnvType", "id"]; let validateFieldName = function(nameInput) { let name = nameInput.value.trim(); if (!name) return; // empty is ok, will be skipped on submit let valid = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); if (!valid) { alert("Invalid field name: '" + name + "'. Must start with a letter or underscore, " + "and contain only letters, numbers, and underscores."); nameInput.focus(); return false; } if (name.startsWith("_hidden_")) { alert("Field names cannot start with '_hidden_'."); nameInput.focus(); return false; } if (reservedNames.indexOf(name) >= 0) { alert("'" + name + "' is a reserved field name."); nameInput.focus(); return false; } // Check for duplicates among all custom field name inputs let allNames = document.querySelectorAll("#customFieldsList .customFieldName"); let count = 0; allNames.forEach(function(inp) { if (inp.value.trim() === name) count++; }); if (count > 1) { alert("Duplicate field name: '" + name + "'."); nameInput.focus(); return false; } return true; }; let addCustomFieldRow = function(existingName) { // existingName: if provided, this is a pre-populated existing field (read-only name) let row = document.createElement("div"); row.className = "customFieldRow"; row.style.cssText = "display:flex; align-items:center; gap:6px; margin-bottom:6px;"; if (existingName) row.dataset.existing = "true"; let nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.className = "customFieldName"; nameInput.placeholder = "Field name"; nameInput.style.cssText = "width:120px;"; if (existingName) { nameInput.value = existingName; nameInput.readOnly = true; nameInput.style.cssText = "width:120px; background:#e8e8e8; color:#555;"; } else { // Add blur validation for new field names nameInput.addEventListener("blur", function() { if (nameInput.value.trim()) validateFieldName(nameInput); }); } let valueInput = document.createElement("input"); valueInput.type = "text"; valueInput.className = "customFieldValue"; valueInput.placeholder = "Value (optional)"; valueInput.style.cssText = "width:180px;"; row.appendChild(nameInput); row.appendChild(valueInput); if (existingName) { // Hide button for existing fields (soft-delete via _hidden_ prefix) let hideBtn = document.createElement("button"); hideBtn.type = "button"; hideBtn.textContent = "Hide"; hideBtn.title = "Hide this custom field (data preserved, can be restored later)"; hideBtn.style.cssText = "border:1px solid #ccc; background:#f5f5f5; color:#888; font-size:11px; cursor:pointer; padding:1px 6px; border-radius:3px;"; hideBtn.addEventListener("click", function() { if (!confirm("Hide the '" + existingName + "' field? Data will be preserved but the field will no longer appear.")) return; // Send ALTER TABLE CHANGE COLUMN request to rename with _hidden_ prefix let hideUrl = "../cgi-bin/hgTracks?hgt_doJsCommand=" + encodeURIComponent("myVariants myVariants " + JSON.stringify({hideField: existingName})) + "&hgt.trackImgOnly=1&hgt.ideogramToo=1&hgsid=" + getHgsid() + "&db=" + getDb(); fetch(hideUrl, { method: "POST", credentials: "same-origin" }) .then(function() { row.remove(); }) .catch(function(err) { alert("Error hiding field: " + err.message); }); }); row.appendChild(hideBtn); } else { // Remove button for newly added rows let removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.textContent = "\u00D7"; removeBtn.title = "Remove this field"; removeBtn.style.cssText = "border:none; background:none; color:#c00; font-size:18px; cursor:pointer; padding:0 4px; line-height:1;"; removeBtn.addEventListener("click", function() { row.remove(); }); row.appendChild(removeBtn); } customFieldsList.appendChild(row); if (!existingName) nameInput.focus(); }; // Pre-populate existing custom fields from server let existingCustomFields = (typeof hgTracks !== 'undefined' && hgTracks.myVariantsCustomFields) ? hgTracks.myVariantsCustomFields : []; existingCustomFields.forEach(function(fieldName) { addCustomFieldRow(fieldName); }); let addBtn = document.createElement("button"); addBtn.type = "button"; addBtn.id = "addCustomFieldBtn"; addBtn.textContent = "+ Add Custom Field"; addBtn.style.cssText = "margin-top:4px; padding:3px 10px; font-size:12px; cursor:pointer;"; addBtn.addEventListener("click", function(event) { event.preventDefault(); addCustomFieldRow(); }); customFieldsSection.appendChild(addBtn); // Hidden fields restore UI let hiddenFields = (typeof hgTracks !== 'undefined' && hgTracks.myVariantsHiddenFields) ? hgTracks.myVariantsHiddenFields : []; if (hiddenFields.length > 0) { let hiddenSection = document.createElement("div"); hiddenSection.id = "hiddenFieldsSection"; hiddenSection.style.cssText = "margin-top:8px; padding:4px 8px; background:#faf8f0; border:1px solid #e0dcc8; border-radius:3px; font-size:12px;"; let hiddenLabel = document.createElement("span"); hiddenLabel.style.cssText = "color:#888; margin-right:8px;"; hiddenLabel.textContent = "Hidden fields:"; hiddenSection.appendChild(hiddenLabel); hiddenFields.forEach(function(fieldName) { let chip = document.createElement("span"); chip.style.cssText = "display:inline-block; margin:2px 4px; padding:2px 6px; background:#e8e8e8; border-radius:3px;"; chip.textContent = fieldName + " "; let restoreBtn = document.createElement("a"); restoreBtn.href = "#"; restoreBtn.textContent = "restore"; restoreBtn.style.cssText = "color:#36c; font-size:11px;"; restoreBtn.addEventListener("click", function(event) { event.preventDefault(); // Add it back as a pre-populated existing field row addCustomFieldRow(fieldName); // Remove the chip chip.remove(); // Hide the section if no more hidden fields if (hiddenSection.querySelectorAll("span[style]").length <= 1) hiddenSection.style.display = "none"; }); chip.appendChild(restoreBtn); hiddenSection.appendChild(chip); }); customFieldsSection.appendChild(hiddenSection); } manualInpDiv.appendChild(customFieldsSection); // Keep the position summary in sync when position fields change ["chrom", "start", "end"].forEach(function(id) { let el = advancedDiv.querySelector("#" + id); if (el) el.addEventListener("input", updatePositionSummary); }); let chromEl = advancedDiv.querySelector("#chrom"); let startEl = advancedDiv.querySelector("#start"); let endEl = advancedDiv.querySelector("#end"); if (chromEl && startEl && endEl) { let startVal = parseInt(startEl.value); let endVal = parseInt(endEl.value); let startFmt = isNaN(startVal) ? startEl.value : startVal.toLocaleString(); let endFmt = isNaN(endVal) ? endEl.value : endVal.toLocaleString(); posSummaryText.textContent = chromEl.value + ":" + startFmt + "-" + endFmt; } form.appendChild(manualInpDiv); // Add hidden field to encode form values as JSON const hiddenInput = document.createElement('input'); hiddenInput.type = 'hidden'; hiddenInput.name = 'hgt_doJsCommand'; form.appendChild(hiddenInput); const trackNameInput = document.createElement('input'); trackNameInput.type = 'hidden'; trackNameInput.id = 'trackName'; trackNameInput.name = 'trackName'; trackNameInput.value = "myVariants"; form.appendChild(trackNameInput); dialogEle.appendChild(form); // Initialize Spectrum color picker after form is added to DOM if (colorInput) { $(colorInput).spectrum({ hideAfterPaletteSelect: true, color: colorInput.value, showPalette: true, showInput: true, showSelectionPalette: true, showInitial: true, preferredFormat: "hex", localStorageKey: "myVariantsColors" }); } // Mount the block editor; reads Start/End from the chrom range fields. // Pass the container element directly: at this point the form is in // the dialog div but the dialog hasn't been appended to body yet, so // getElementById would still miss "blocksContainer". // Stash the widget handle on the form so createItem can call validate(). if (typeof myVariantsBlocks !== "undefined") { form.blocksWidget = myVariantsBlocks.mount(blocksContainer, { getStart: function () { return parseInt(document.getElementById("start").value, 10); }, getEnd: function () { return parseInt(document.getElementById("end").value, 10); }, hiddenCountInput: hCount, hiddenSizesInput: hSizes, hiddenStartsInput: hStarts }); } // Stash applyMode on the form so init() can restore last-used mode. form.applyMode = applyMode; return form; }, init: function () { // show a jquery-ui dialog when a user clicks on the 'Add Annotation' button let dialog = document.getElementById('myVariantsDialog'); if (!dialog) { dialog = document.createElement("div"); dialog.id = "myVariantsDialog"; dialog.style = "display: none"; dialogButtons = {}; // Call the function to build the form, but only if logged in already if (!userIsLoggedIn) { let msg = document.createElement("div"); msg.id = "logInMessage"; let href = (typeof myVariantsLoginUrl !== "undefined" && myVariantsLoginUrl) ? myVariantsLoginUrl : "./hgSession"; let link = document.createElement("a"); link.href = href; link.textContent = "log in"; msg.appendChild(document.createTextNode("Please ")); msg.appendChild(link); msg.appendChild(document.createTextNode(" to use this feature.")); dialog.appendChild(msg); } else { let form = this.createBedForm(dialog); document.body.append(dialog); dialogButtons.Submit = function() { // extract the form elements and check myVariants.createItem(form); }; } dialogButtons.Cancel = function(){ $(this).dialog("close"); }; $(dialog).dialog({ title: "My Annotations", resizable: false, height: "auto", width: 580, modal: true, closeOnEscape: true, autoOpen: false, buttons: dialogButtons, open: function() { // Restore the last input mode the user picked (or default to HGVS). let form = document.getElementById("myVariants-form"); if (form && form.applyMode) { let saved; try { saved = localStorage.getItem("myVariantsLastMode"); } catch (e) { saved = null; } const validModes = ["hgvs", "transcript", "snv", "cnv"]; form.applyMode(validModes.indexOf(saved) >= 0 ? saved : "hgvs"); } }, close: function() { // Reset block rows so the next open starts clean. let form = document.getElementById("myVariants-form"); if (form && form.blocksWidget) { form.blocksWidget.clear(); } } }); } else { // Reopening after an async image update; refresh the position fields. // The dialog's open callback re-applies the last-used mode. let form = document.getElementById("myVariants-form"); let start = form.elements.start; let end = form.elements.end; let thickStart = form.elements.thickStart; let thickEnd = form.elements.thickEnd; if (start) start.value = hgTracks.winStart; if (end) end.value = hgTracks.winEnd; if (thickStart) thickStart.value = hgTracks.winStart; if (thickEnd) thickEnd.value = hgTracks.winEnd; let summaryText = document.getElementById("positionSummaryText"); if (summaryText) { let chromEl = document.getElementById("chrom"); let startFmt = parseInt(hgTracks.winStart).toLocaleString(); let endFmt = parseInt(hgTracks.winEnd).toLocaleString(); summaryText.textContent = (chromEl ? chromEl.value : hgTracks.chromName) + ":" + startFmt + "-" + endFmt; } } // if we clicked outside of the pop up, close the popup: document.addEventListener('click', (e) => { let dialogEl = document.getElementById("myVariantsDialog"); if (!dialogEl) return; // If the click handler that ran first removed its own target from // the document (eg a row remove button, or a Grammarly/extension // DOM swap on the description field), e.target is now detached // and `.contains(e.target)` would falsely report "outside". Skip. if (!document.contains(e.target)) return; let dialogContainer = dialogEl.parentElement; // Check if click target is inside the dialog (handles native dropdowns that render outside bounds) if (dialogContainer && !dialogContainer.contains(e.target)) { $("#myVariantsDialog").dialog("close"); } }); }, createItem: function(form) { // sends a post to hgTracks that adds a new item to the users custom track // and updates the image to include this track if it wasn't already there // Block validation only matters for the transcript path; SNV/CNV don't carry blocks. let blockResult = {ok: true, noBlocks: true}; const isTranscript = !!(document.getElementById("typeTranscript") && document.getElementById("typeTranscript").checked); if (isTranscript && form.blocksWidget && form.blocksWidget.getRowCount() > 0) { blockResult = form.blocksWidget.validate(); if (!blockResult.ok) { alert("Block error: " + blockResult.msg); return; } } const data = {}; if (form.elements.hgvsInput.value) { data.hgvsInput = form.elements.hgvsInput.value; } else { const itemType = isTranscript ? "transcript" : (document.getElementById("typeCnv").checked ? "cnv" : "snv"); Array.from(form.elements).forEach( (ele) => { if (ele.name === "myVariantsHgvsInput" || ele.name === "hgt_doJsCommand" || ele.name === "myVariantsType" || (ele.tagName !== "INPUT" && ele.tagName !== "TEXTAREA")) {return;} const key = ele.id; let value = ele.value; // Handle Spectrum color picker - get the value from spectrum if available if (ele.id === "color" && $(ele).spectrum) { let spectrumColor = $(ele).spectrum("get"); if (spectrumColor) { value = spectrumColor.toHexString(); } } data[key] = value; }); data.itemType = itemType; data.cnvType = (itemType === "cnv") ? document.getElementById("cnvType").value : ""; if (itemType === "cnv") data.ref = ""; else if (itemType === "transcript") { data.ref = ""; data.alt = ""; } // Collect custom fields from the dynamic rows let customRows = document.querySelectorAll("#customFieldsList .customFieldRow"); if (customRows.length > 0) { let customFields = []; customRows.forEach(function(row) { let name = row.querySelector(".customFieldName").value.trim(); if (name) { let value = row.querySelector(".customFieldValue").value; customFields.push({name: name, value: value}); } }); if (customFields.length > 0) { data.extraFields = customFields; } } // Convert hidden block fields from CSV strings to arrays of ints. // Drop blocks entirely for SNV/CNV; server synthesizes a single full-span // block. For transcript with noBlocks (rows added but every size empty), // also drop and let the server synthesize. let bc = parseInt(data.blockCount, 10); if (!isTranscript || !bc || blockResult.noBlocks) { delete data.blockCount; delete data.blockSizes; delete data.chromStarts; } else { data.blockCount = bc; data.blockSizes = data.blockSizes.split(",").map(Number); data.chromStarts = data.chromStarts.split(",").map(Number); } } // Show loading indicator const loadingId = showLoadingImage("imgTbl"); document.body.style.cursor = "wait"; // Build request - use fetch() instead of form.submit() const trackName = form.elements.namedItem("trackName").value; const req = encodeURIComponent(`myVariants ${trackName} ${JSON.stringify(data)}`); const url = cart.addUpdatesToUrl(`../cgi-bin/hgTracks?hgt_doJsCommand=${req}&trackName=${trackName}&hgt.trackImgOnly=1&hgt.ideogramToo=1&hgsid=${getHgsid()}&db=${getDb()}`); fetch(url, { method: "POST", credentials: "same-origin" }) .then(response => { if (!response.ok) { throw new Error("Network response was not ok: " + response.status); } return response.text(); }) .then(html => { hideLoadingImage(loadingId); document.body.style.cursor = ""; myVariants.handleCreateSuccess(html, data); }) .catch(error => { hideLoadingImage(loadingId); document.body.style.cursor = ""; warn("Error creating annotation: " + error.message); console.error("Fetch error:", error); }); }, handleCreateSuccess: function(response, data) { // Close the create dialog $("#myVariantsDialog").dialog("close"); // Update the image using the existing pattern imageV2.updateImgAndMap.call({cmd: 'wholeImage'}, response, 'success'); // Extract new item coordinates from server response const newItemPos = scrapeVariable(response, "newItemPos"); let variantChrom, variantStart, variantEnd; if (newItemPos) { // Server returned the coordinates variantChrom = newItemPos.chrom; variantStart = newItemPos.start; variantEnd = newItemPos.end; } else { // Fallback: try to get from form data (manual entry) variantChrom = data.chrom || hgTracks.chromName; variantStart = parseInt(data.start, 10) || hgTracks.winStart; variantEnd = parseInt(data.end, 10) || hgTracks.winEnd; } // Check if variant is in current window const inCurrentWindow = (variantChrom === hgTracks.chromName && variantStart < hgTracks.winEnd && variantEnd > hgTracks.winStart); if (inCurrentWindow) { // Already visible - just show brief success message return; } // Check stored preference const navPref = localStorage.getItem("myVariants_navPref"); if (navPref === "jump") { // Auto-navigate to variant myVariants.navigateToVariant(variantChrom, variantStart, variantEnd); return; } else if (navPref === "stay") { // Stay here, just show message warn("Annotation created at " + variantChrom + ":" + (variantStart+1).toLocaleString() + "-" + variantEnd.toLocaleString()); return; } // No preference saved - show dialog myVariants.showNavigationDialog(variantChrom, variantStart, variantEnd); }, showNavigationDialog: function(chrom, start, end) { const posStr = chrom + ":" + (start+1).toLocaleString() + "-" + end.toLocaleString(); // Create dialog content const content = document.createElement("div"); // Build message with position const msg = document.createElement("p"); msg.style.marginBottom = "0.5em"; msg.innerHTML = "Annotation created at " + posStr + ""; content.appendChild(msg); // Show dialog with buttons $(content).dialog({ title: "Annotation Created", modal: true, width: 600, buttons: { "Go to Annotation": function() { if (document.getElementById("myVariantsRememberNav").checked) { localStorage.setItem("myVariants_navPref", "jump"); } $(this).dialog("close"); myVariants.navigateToVariant(chrom, start, end); }, "Stay Here": function() { if (document.getElementById("myVariantsRememberNav").checked) { localStorage.setItem("myVariants_navPref", "stay"); } $(this).dialog("close"); } }, open: function() { // Add checkbox to button pane, inline with buttons const dialog = $(this); const buttonPane = dialog.closest(".ui-dialog").find(".ui-dialog-buttonpane"); const checkboxSpan = document.createElement("span"); checkboxSpan.style.cssText = "display: inline-block; vertical-align: middle; margin-right: 1em;"; checkboxSpan.innerHTML = ""; checkboxSpan.appendChild(createInfoIcon( "Save your preference. Future annotations outside the current view will automatically " + "use this choice. Reset via Configure page or cartReset." )); // Insert before the button set buttonPane.find(".ui-dialog-buttonset").before(checkboxSpan); // Force dialog to recalculate its size dialog.dialog("option", "height", "auto"); } }); }, navigateToVariant: function(chrom, start, end) { // Add small padding around the variant (max 100bp) const padding = 100; const paddedStart = Math.max(0, start - padding); const paddedEnd = end + padding; // Navigate using existing mechanism const pos = chrom + ":" + paddedStart + "-" + paddedEnd; imageV2.navigateInPlace("position=" + pos, null, true); }, showDialog: function() { let dialog = document.getElementById('myVariantsDialog'); // Clear dynamically-added custom field rows; for existing fields, just clear the value let customFieldsList = document.getElementById("customFieldsList"); if (customFieldsList) { let rows = customFieldsList.querySelectorAll(".customFieldRow"); rows.forEach(function(row) { if (row.dataset.existing === "true") { // Existing field - just clear the value input let valInput = row.querySelector(".customFieldValue"); if (valInput) valInput.value = ""; } else { // Dynamically added row - remove it row.remove(); } }); } $(dialog).dialog("open"); }, }; ///////////////// //// posting //// ///////////////// var posting = { blockUseMap: false, blockMapClicks: function () // Blocks clicking on map items when in effect. Drag opperations frequently call this. { posting.blockUseMap=true; }, allowMapClicks:function () // reallows map clicks. Called after operations that compete with clicks (e.g. dragging) { $('body').css('cursor', ''); // Explicitly remove wait cursor. posting.blockUseMap=false; }, mapClicksAllowed: function () // Verify that click-competing operation (e.g. dragging) isn't currently occurring. { return (posting.blockUseMap === false); }, blockTheMapOnMouseMove: function (ev) { if (!posting.blockUseMap && mouse.hasMoved(ev)) { posting.blockUseMap=true; } }, mapClk: function () { var done = false; // if we clicked on a merged item then show all the items, similar to clicking a // dense track to turn it to pack if (!(this && this.href)) { writeToApacheLog(`no href for mapClk, this = `); } let parsedUrl = parseUrl(this.href); let cgi = parsedUrl.cgi; // for some reason, '-' characters are encoded here? unencode them so lookups into // hgTracks.trackDb will work let id = ""; if (typeof parsedUrl.queryArgs.g !== "undefined") { id = parsedUrl.queryArgs.g.replace("%2D", "-"); } if (parsedUrl.queryArgs.i === "mergedItem") { updateObj={}; updateObj[id+".doMergeItems"] = 0; hgTracks.trackDb[id][id+".doMergeItems"] = 0; cart.setVarsObj(updateObj,null,false); imageV2.requestImgUpdate(id, id + ".doMergeItems=0"); return false; } if (done) return false; else { // first check if we are allowed to click, we could be in the middle // of a drag select if (posting.blockUseMap === true) { return false; } else if (cgi === "hgGene") { window.hgcOrHgGeneArgs = parsedUrl.queryArgs; id = parsedUrl.queryArgs.hgg_type; popUpHgcOrHgGene.hgc(id, this.href); return false; } else if (cgi === "hgTrackUi") { rec = hgTracks.trackDb[id]; if (rec && tdbIsLeaf(rec)) { popUp.hgTrackUi(id, false); } else { location.assign(href); } } else if (cgi === "hgc") { if (id.startsWith("multiz")) { // multiz tracks have a form that lets you change highlighted bases // that does not play well in a pop up // toga tracks require bootstrap which does not work with something location.assign(href); return false; } popUpHgcOrHgGene.hgc(id, this.href); return false; } else { // must be changing the density of a track, save the settings and post the form: return posting.saveSettings(this); } } }, saveSettings: function (obj) { if (posting.blockUseMap === true) { return false; } if (!obj || !obj.href) // called directly with obj obj = this; // and from callback without obj if ($(obj).hasClass('noLink')) // TITLE_BUT_NO_LINK return false; if (obj.href.match('#') || obj.target.length > 0) { //alert("Matched # ["+obj.href+"] or has target:"+obj.target); return true; } var thisForm = normed($(obj).parents('form').first()); if (!thisForm) thisForm = normed($("FORM").first()); if (thisForm) { //alert("posting form:"+$(thisForm).attr('name')); return postTheForm($(thisForm).attr('name'),cart.addUpdatesToUrl(obj.href)); } return true; } }; ///////////////////////// //// cart updating ///// /////////////////////// var cart = { // Controls queuing and ultimately updating cart variables vis ajax or submit. Queued vars // are held in an object with unique keys preventing duplicate updates and ensuring last update // takes precedence. WARNING: be careful creating an object with variables on the fly: // cart.setVarsObj({track: vis}) is invalid but cart.setVarsObj({'knownGene': vis}) is ok! updateQueue: {}, updatesWaiting: function () { // returns TRUE if updates are waiting. return objNotEmpty(cart.updateQueue); }, addUpdatesToUrl: function (url) { // adds any outstanding cart updates to the url, then clears the queue if (cart.updatesWaiting()) { //console.log('cart.addUpdatesToUrl: '+objKeyCount(cart.updateQueue)+' vars'); var updates = cart.varsToUrlData(); // clears the queue if (!url || url.length === 0) return updates; if (updates.length > 0) { var dataOnly = (url.indexOf("cgi-bin") === -1); // all urls should be to our cgis if (!dataOnly && url.lastIndexOf("?") === -1) url += "?" + updates; else url += '&' + updates; } } return url; }, varsToUrlData: function (varsObj) { // creates a url data (var1=val1&var2=val2...) string from vars object and queue // The queue will be emptied by this call. cart.queueVarsObj(varsObj); // lay ontop of queue, to give new values precedence // Now convert to url data and clear queue var urlData = ''; if (cart.updatesWaiting()) { //console.log('cart.varsToUrlData: '+objKeyCount(cart.updateQueue)+' vars'); urlData = varHashToQueryString(cart.updateQueue); cart.updateQueue = {}; } return urlData; }, setVarsObj: function (varsObj, errFunc, async) { // Set all vars in a var hash, appending any queued updates // The default behavior is async = true //console.log('cart.setVarsObj: were:'+objKeyCount(cart.updateQueue) + // ' new:'+objKeyCount(varsObj); cart.queueVarsObj(varsObj); // lay ontop of queue, to give new values precedence // Now ajax update all in queue and clear queue if (cart.updatesWaiting()) { setVarsFromHash(cart.updateQueue, errFunc, async); cart.updateQueue = {}; } }, setVars: function (names, values, errFunc, async) { // ajax updates the cart, and includes any queued updates. cart.updateSessionPanel(); // handles hide from left minibutton cart.setVarsObj(arysToObj(names, values), errFunc, async); }, queueVarsObj: function (varsObj) { // Add object worth of cart updates to the 'to be updated' queue, so they can be sent to // the server later. Note: hash allows overwriting previous updates to the same variable. if (typeof varsObj !== 'undefined' && objNotEmpty(varsObj)) { //console.log('cart.queueVarsObj: were:'+objKeyCount(cart.updateQueue) + // ' new:'+objKeyCount(varsObj)); for (var name in varsObj) { cart.updateQueue[name] = varsObj[name]; } } }, addVarsToQueue: function (names,values) { // creates a string of updates to save for ajax batch or a submit cart.queueVarsObj(arysToObj(names,values)); }, updateSessionPanel: function() /* This function tries to find out if the current cart changed enough. It is not used anymore, as the * current active rec. track set name is not shown anymore */ { // this is never set anymore if (typeof recTrackSetsDetectChanges === 'undefined' || recTrackSetsDetectChanges === null) return; // change color of text $('span.gbSessionChangeIndicator').addClass('gbSessionChanged'); // change mouseover on the panel. A bit fragile here inserting text in the mouseover specified in // hgTracks.js, so depends on match with text there, and should present same message as C code // (Perhaps this could be added as a script tag, so not duplicated) var txt = $('span.gbSessionLabelPanel').attr('title'); if (txt && !txt.match(/with changes/)) { $('span.gbSessionLabelPanel').attr('title', txt.replace( "track set", "track set, with changes (added or removed tracks) you have requested")); } } }; /////////////////////////////////////////////// //// visibility (mixed with group toggle) ///// /////////////////////////////////////////////// var vis = { // map cgi enum visibility codes to strings enumOrder: new Array("hide", "dense", "full", "pack", "squish"), update: function (track, visibility) { // Updates visibility state in hgTracks.trackDb and any visible elements on the page. // returns true if we modify at least one select in the group list var rec = hgTracks.trackDb[track]; var selectUpdated = false; $("select[name=" + escapeJQuerySelectorChars(track) + "]").each(function(t) { $(this).attr('class', visibility === 'hide' ? 'hiddenText' : 'normalText'); $(this).val(visibility); selectUpdated = true; }); if (rec) { rec.localVisibility = visibility; } return selectUpdated; }, get: function (track) { // return current visibility for given track var rec = hgTracks.trackDb[track]; if (rec) { if (rec.localVisibility) { return rec.localVisibility; } else { return vis.enumOrder[rec.visibility]; } } else { return null; } }, makeTrackVisible: function (track) { // Sets the vis box to visible, and ques a cart update, but does not update the image // Trusts that the cart update will be submitted later. if (track && vis.get(track) !== "full") { vis.update(track, 'pack'); cart.addVarsToQueue([track], ['pack']); } }, toggleForGroup: function (button, prefix) { // toggle visibility of a track group; prefix is the prefix of all the id's of tr's in the // relevant group. This code also modifies the corresponding hidden fields and the gif // of the +/- img tag. imageV2.markAsDirtyPage(); if (arguments.length > 2) return setTableRowVisibility(button, prefix, "hgtgroup", "group",false,arguments[2]); else return setTableRowVisibility(button, prefix, "hgtgroup", "group", true); }, expandAllGroups: function (newState) { // Set visibility of all track groups to newState (true means expanded). // This code also modifies the corresponding hidden fields and the gif's of the +/- img tag. imageV2.markAsDirtyPage(); $(".toggleButton[id$='_button']").each( function (i) { // works for old img type AND new BUTTONS_BY_CSS // - 7: clip '_button' suffix vis.toggleForGroup(this,this.id.substring(0,this.id.length - 7),newState); }); return false; }, initForAjax: function() { // To better support the back-button, it is good to eliminate extraneous form puts // Towards that end, we support visBoxes making ajax calls to update cart. var sels = $('select.normalText,select.hiddenText'); $(sels).on("change", function() { var track = $(this).attr('name'); let newVis = $(this).val(); if (newVis === 'hide') { var rec = hgTracks.trackDb[track]; if (rec) rec.visibility = 0; // else Would be nice to hide subtracks as well but that may be overkill $(document.getElementById('tr_' + track)).remove(); cart.updateSessionPanel(); imageV2.drawHighlights(); $(this).attr('class', 'hiddenText'); } else $(this).attr('class', 'normalText'); document.querySelectorAll('[name="'+track+'"]').forEach( (sel) => { sel.value = newVis; }); cart.setVars([track], [$(this).val()]); imageV2.markAsDirtyPage(); return false; }); // Now we can rid the submt of the burden of all those vis boxes var form = $('form#TrackForm'); $(form).on("submit", function () { $('select.normalText,select.hiddenText').prop('disabled',true); }); $(form).attr('method','get'); }, restoreFromBackButton: function() // Re-enabling vis dropdowns is necessary because initForAjax() disables them on submit. { $('select.normalText,select.hiddenText').prop('disabled',false); } }; //////////////////////////////////////////////////////////// // dragSelect is also known as dragZoom or shift-dragZoom // //////////////////////////////////////////////////////////// var dragSelect = { hlColor : '#aac6ff', // current highlight color hlColorDefault: '#aac6ff', // default highlight color, if nothing specified areaSelector: null, // formerly "imgAreaSelect". jQuery element used for imgAreaSelect originalCursor: null, startTime: null, escPressed : false, // flag is set when user presses Escape selectStart: function (img, selection) { initVars(); dragSelect.escPressed = false; if (rightClick.menu) { rightClick.menu.hide(); } var now = new Date(); dragSelect.startTime = now.getTime(); posting.blockMapClicks(); }, selectChange: function (img, selection) { if (selection.x1 !== selection.x2) { if (genomePos.check(img, selection)) { genomePos.update(img, selection, false); jQuery('body').css('cursor', dragSelect.originalCursor); } else { jQuery('body').css('cursor', 'not-allowed'); } } return true; }, findHighlightIdxForPos : function(findPos) { // return the index of the highlight string e.g. hg19.chrom1:123-345#AABBDCC that includes a chrom range findPos // mostly copied from drawHighlights() var currDb = getDb(); if (hgTracks.highlight) { var hlArray = hgTracks.highlight.split("|"); // support multiple highlight items for (var i = 0; i < hlArray.length; i++) { hlString = hlArray[i]; pos = parsePositionWithDb(hlString); imageV2.undisguiseHighlight(pos); if (!pos) continue; // ignore invalid position strings pos.start--; if (pos.chrom === hgTracks.chromName && pos.db === currDb && pos.start <= findPos.chromStart && pos.end >= findPos.chromEnd) { return i; } } } return null; }, saveHlColor : function (hlColor) // save the current hlColor to the object and also the cart variable hlColor and return it. // hlColor is a 6-character hex string prefixed by # { dragSelect.hlColor = hlColor; cart.setVars(["prevHlColor"], [dragSelect.hlColor], null, false); hgTracks.prevHlColor = hlColor; // cart.setVars does not update the hgTracks-variables. The cart-variable system is a problem. return hlColor; }, loadHlColor : function () // load hlColor from prevHlColor in the cart, or use default color, set and return it // color is a 6-char hex string prefixed by # { if (hgTracks.prevHlColor) dragSelect.hlColor = hgTracks.prevHlColor; else dragSelect.hlColor = dragSelect.hlColorDefault; return dragSelect.hlColor; }, highlightThisRegion: function(newPosition, doAdd, hlColor) // set highlighting newPosition in server-side cart and apply the highlighting in local UI. // hlColor can be undefined, in which case it defaults to the last used color or the default light blue // if hlColor is set, it is also saved into the cart. // if doAdd is true, the highlight is added to the current list. If it is false, all old highlights are deleted. { newPosition.replace("virt:", "multi:"); var hlColorName = null; if (hlColor==="" || hlColor===null || hlColor===undefined) hlColorName = dragSelect.loadHlColor(); else hlColorName = dragSelect.saveHlColor( hlColor ); var pos = parsePosition(newPosition); var start = pos.start; var end = pos.end; var newHighlight = makeHighlightString(getDb(), pos.chrom, start, end, hlColorName); newHighlight = imageV2.disguiseHighlight(newHighlight); var oldHighlight = hgTracks.highlight; if (oldHighlight===undefined || doAdd===undefined || doAdd===false || oldHighlight==="") { // just set/overwrite the old highlight position, this used to be the default hgTracks.highlight = newHighlight; } else { // add to the end of a |-separated list hgTracks.highlight = oldHighlight+"|"+newHighlight; } // we include enableHighlightingDialog because it may have been changed by the dialog var cartSettings = { 'highlight': hgTracks.highlight, 'enableHighlightingDialog': hgTracks.enableHighlightingDialog ? 1 : 0 }; if (hgTracks.windows && !hgTracks.virtualSingleChrom) { var nonVirtChrom = ""; var nonVirtStart = -1; var nonVirtEnd = -1; for (i=0,len=hgTracks.windows.length; i < len; ++i) { var w = hgTracks.windows[i]; // overlap with new position? if (w.virtEnd > start && end > w.virtStart) { var s = Math.max(start, w.virtStart); var e = Math.min(end, w.virtEnd); var cs = s - w.virtStart + w.winStart; var ce = e - w.virtStart + w.winStart; if (nonVirtChrom === "") { nonVirtChrom = w.chromName; nonVirtStart = cs; nonVirtEnd = ce; } else { if (w.chromName === nonVirtChrom) { nonVirtEnd = Math.max(ce, nonVirtEnd); } else { break; } } } } if (nonVirtChrom !== "") cartSettings.nonVirtHighlight = makeHighlightString(getDb(), nonVirtChrom, nonVirtStart, (nonVirtEnd+1), hlColorName); } else if (hgTracks.windows && hgTracks.virtualSingleChrom) { cartSettings.nonVirtHighlight = hgTracks.highlight; } // TODO if not virt, do we need to erase cart nonVirtHighlight ? cart.setVarsObj(cartSettings); imageV2.drawHighlights(); }, selectionEndDialog: function (newPosition) // Let user choose between zoom-in and highlighting. { newPosition.replace("virt:", "multi:"); // if the user hit Escape just before, do not show this dialo if (dragSelect.startTime===null) return; var dragSelectDialog = $("#dragSelectDialog")[0]; if (!dragSelectDialog) { $("body").append("
" + "

    "+ "
  • Hold Shift+drag to show this dialog" + "
  • Hold Alt+drag (Windows) or Option+drag (Mac) to add a highlight" + "
  • Hold Ctrl+drag (Windows) or Cmd+drag (Mac) to zoom" + "
  • To cancel, press Esc anytime during the drag" + "
  • Using the keyboard, highlight the current position with h then m" + "
  • Clear all highlights with View - Clear Highlights or h then c" + "
  • Clear specific highlights with right click > Remove highlight" + "
  • To merely save the color for the next keyboard or right-click > Highlight operations, click 'Save Color' below" + "

"); makeHighlightPicker("hlColor", document.getElementById("dragSelectDialog"), null); $("#dragSelectDialog").append("
" + "Don't show this again and always zoom with shift.
" + "Re-enable via 'View - Configure Browser' (c then f)
"+ "Selected chromosome position: "); dragSelectDialog = $("#dragSelectDialog")[0]; // reset value // allow to click checkbox by clicking on the label $('#hlNotShowAgainMsg').on("click", function() { $('#disableDragHighlight').trigger("click");}); // click "add highlight" when enter is pressed in color input box $("#hlColorInput").on("keyup", function(event){ if(event.keyCode == 13){ $(".ui-dialog-buttonset button:nth-child(3)").trigger("click"); } }); } if (hgTracks.windows) { var i,len; var newerPosition = newPosition; if (hgTracks.virtualSingleChrom && (newPosition.search("multi:")===0)) { newerPosition = genomePos.disguisePosition(newPosition); } var str = newerPosition + "
\n"; var str2 = "
\n"; str2 += "
    \n"; var pos = parsePosition(newPosition); var start = pos.start - 1; var end = pos.end; var selectedRegions = 0; for (i=0,len=hgTracks.windows.length; i < len; ++i) { var w = hgTracks.windows[i]; // overlap with new position? if (w.virtEnd > start && end > w.virtStart) { var s = Math.max(start, w.virtStart); var e = Math.min(end, w.virtEnd); var cs = s - w.virtStart + w.winStart; var ce = e - w.virtStart + w.winStart; str2 += "
  • " + w.chromName + ":" + (cs+1) + "-" + ce + "
  • \n"; selectedRegions += 1; } } str2 += "
\n"; if (!(hgTracks.virtualSingleChrom && (selectedRegions === 1))) { str += str2; } $("#dragSelectPosition").html(str); } else { $("#dragSelectPosition").html(newPosition); } // Build the button set in two passes so that "Create Item" only // appears when myVariants is enabled in hg.conf. doMyVariants is // emitted by the server only inside the gated hg.conf block, so on // a server with the feature off the variable is undefined and the // button is omitted entirely. let dragSelectButtons = { "Zoom In": function() { // Zoom to selection $(this).dialog("option", "revertToOriginalPos", false); if ($("#disableDragHighlight").prop('checked')) hgTracks.enableHighlightingDialog = false; if (imageV2.inPlaceUpdate) { if (hgTracks.virtualSingleChrom && (newPosition.search("multi:")===0)) { newPosition = genomePos.disguisePosition(newPosition); // DISGUISE } var params = "db=" + getDb() + "&position=" + newPosition; if (!hgTracks.enableHighlightingDialog) params += "&enableHighlightingDialog=0"; imageV2.navigateInPlace(params, null, true); } else { $('body').css('cursor', 'wait'); if (!hgTracks.enableHighlightingDialog) cart.setVarsObj({'enableHighlightingDialog': 0 },null,false); // async=false document.TrackHeaderForm.submit(); } $(this).dialog("close"); }, "Single Highlight": function() { // Clear old highlight and Highlight selection $(imageV2.imgTbl).imgAreaSelect({hide:true}); if ($("#disableDragHighlight").prop('checked')) hgTracks.enableHighlightingDialog = false; var hlColor = $("#hlColorInput").val(); dragSelect.highlightThisRegion(newPosition, false, hlColor); $(this).dialog("close"); }, "Add Highlight": function() { // Highlight selection if ($("#disableDragHighlight").prop('checked')) hgTracks.enableHighlightingDialog = false; var hlColor = $("#hlColorInput").val(); dragSelect.highlightThisRegion(newPosition, true, hlColor); $(this).dialog("close"); }, "Save Color": function() { var hlColor = $("#hlColorInput").val(); dragSelect.saveHlColor( hlColor ); $(this).dialog("close"); } }; if (typeof doMyVariants !== 'undefined' && doMyVariants) { dragSelectButtons["Add Annotation"] = function() { let data = {}; let pos = parsePosition(newPosition); data.chrom = pos.chrom; data.start = data.thickStart = pos.start.toString(); data.end = data.thickEnd = pos.end.toString(); data.score = "0"; data.strand = "."; data.color = $("#hlColorInput").val(); data.name = ""; data.description = ""; data.ref = ""; data.alt = ""; data.trackName = "myVariants"; $(this).dialog("close"); let req = encodeURIComponent(`myVariants myVariants ${JSON.stringify(data)}`); jQuery('body').css('cursor', 'wait'); $.ajax({ type: "POST", url: "../cgi-bin/hgTracks", data: cart.addUpdatesToUrl(`hgt_doJsCommand=${req}&trackName=myVariants&hgsid=${getHgsid()}`), dataType: "html", trueSuccess: imageV2.updateImgAndMap, success: catchErrorOrDispatch, error: errorHandler, cmd: 'wholeImage', loadingId: showLoadingImage("imgTbl"), cache: false }); }; } dragSelectButtons.Cancel = function() { $(this).dialog("close"); }; $(dragSelectDialog).dialog({ modal: true, title: "Drag-and-select", closeOnEscape: true, resizable: false, autoOpen: false, revertToOriginalPos: true, minWidth: 750, buttons: dragSelectButtons, open: function () { // Make zoom the focus/default action $(this).parents('.ui-dialog-buttonpane button:eq(0)').trigger("focus"); }, close: function() { // All exits to dialog should go through this $(imageV2.imgTbl).imgAreaSelect({hide:true}); if ($(this).dialog("option", "revertToOriginalPos")) genomePos.revertToOriginalPos(); if ($("#disableDragHighlight").prop('checked')) $(this).remove(); else $(this).hide(); $('body').css('cursor', ''); // Occasionally wait cursor got left behind $("#hlColorPicker").spectrum("hide"); } }); $(dragSelectDialog).dialog('open'); }, selectEnd: function (img, selection, event) { var now = new Date(); var doIt = false; var rulerClicked = selection.y1 <= hgTracks.rulerClickHeight; // = drag on base position track (no shift) if (dragSelect.originalCursor) jQuery('body').css('cursor', dragSelect.originalCursor); if (dragSelect.escPressed) return false; // ignore releases outside of the image rectangle (allowing a 10 pixel slop) if (genomePos.check(img, selection)) { // ignore single clicks that aren't in the top of the image // (this happens b/c the clickClipHeight test in dragSelect.selectStart // doesn't occur when the user single clicks). doIt = (dragSelect.startTime !== null || rulerClicked); } if (doIt) { // dragSelect.startTime is null if mouse has never been moved var singleClick = ( (selection.x2 === selection.x1) || dragSelect.startTime === null || (now.getTime() - dragSelect.startTime) < 100); var newPosition = genomePos.update(img, selection, singleClick); newPosition.replace("virt:", "multi:"); if (newPosition) { if (event.altKey) { // with the alt-key, only highlight the region, do not zoom dragSelect.highlightThisRegion(newPosition, true); $(imageV2.imgTbl).imgAreaSelect({hide:true}); } else { if (hgTracks.enableHighlightingDialog && !(event.metaKey || event.ctrlKey)) // don't show the dialog if: clicked on ruler, if dialog deactivated or meta/ctrl was pressed dragSelect.selectionEndDialog(newPosition); else { // in every other case, show the dialog $(imageV2.imgTbl).imgAreaSelect({hide:true}); if (imageV2.inPlaceUpdate) { if (hgTracks.virtualSingleChrom && (newPosition.search("multi:")===0)) { newPosition = genomePos.disguisePosition(newPosition); // DISGUISE } imageV2.navigateInPlace("db=" + getDb() + "&position=" + newPosition, null, true); } else { jQuery('body').css('cursor', 'wait'); document.TrackHeaderForm.submit(); } } } } else { $(imageV2.imgTbl).imgAreaSelect({hide:true}); genomePos.revertToOriginalPos(); } dragSelect.startTime = null; // blockMapClicks/allowMapClicks() is necessary if selectEnd was over a map item. setTimeout(posting.allowMapClicks,50); return true; } }, load: function (firstTime) { var imgHeight = 0; if (imageV2.enabled) imgHeight = imageV2.imgTbl.innerHeight() - 1; // last little bit makes border look ok // No longer disable without ruler, because shift-drag still works if (typeof(hgTracks) !== "undefined") { if (hgTracks.rulerClickHeight === undefined || hgTracks.rulerClickHeight === null) hgTracks.rulerClickHeight = 0; // will be zero if no ruler track var heights = hgTracks.rulerClickHeight; var xLimits = genomePos.getXLimits($(imageV2.imgTbl), 0); dragSelect.areaSelector = jQuery((imageV2.imgTbl).imgAreaSelect({ minX : xLimits[0], maxX : xLimits[1], selectionColor: 'blue', outerColor: '', minHeight: imgHeight, maxHeight: imgHeight, onSelectStart: dragSelect.selectStart, onSelectChange: dragSelect.selectChange, onSelectEnd: dragSelect.selectEnd, autoHide: false, // gets hidden after possible dialog movable: false, clickClipHeight: heights })); // remove any ongoing drag-selects when the esc key is pressed anywhere for this document // This allows to abort zooming / highlighting $(document).on("keyup", function(e){ if(e.keyCode === 27) { $(imageV2.imgTbl).imgAreaSelect({hide:true}); dragSelect.escPressed = true; } }); // hide and redraw all current highlights when the browser window is resized $(window).on("resize", function() { $(imageV2.imgTbl).imgAreaSelect({hide:true}); imageV2.drawHighlights(); }); } } }; ///////////////////////////////////// //// Chrom Drag/Zoom/Expand code //// ///////////////////////////////////// jQuery.fn.chromDrag = function(){ this.each(function(){ // Plan: // mouseDown: determine where in map: convert to img location: pxDown // mouseMove: flag drag // mouseUp: if no drag, then create href centered on bpDown loc with current span // if drag, then create href from bpDown to bpUp // if ctrlKey then expand selection to containing cytoBand(s) // Image dimensions all in pix var img = { top: -1, scrolledTop: -1, height: -1, left: -1, scrolledLeft: -1, width: -1 }; // chrom Dimensions beg,end,size in bases, rest in pix var chr = { name: "", reverse: false, beg: -1, end: -1, size: -1, top: -1, bottom: -1, left: -1, right: -1, width: -1 }; var pxDown = 0; // pix X location of mouseDown var chrImg = $(this); var mouseIsDown = false; var mouseHasMoved = false; var hilite = null; initialize(); function initialize(){ findDimensions(); if (chr.top === -1) warn("chromIdeo(): failed to register "+this.id); else { hiliteSetup(); $('area.cytoBand').off('mousedown'); // Make sure this is only bound once $('area.cytoBand').on("mousedown", function(e) { // mousedown on chrom portion of image only (map items) updateImgOffsets(); pxDown = e.clientX - img.scrolledLeft; var pxY = e.clientY - img.scrolledTop; if (mouseIsDown === false && isWithin(chr.left,pxDown,chr.right) && isWithin(chr.top,pxY,chr.bottom)) { mouseIsDown = true; mouseHasMoved = false; $(document).on('mousemove',chromMove); $(document).on( 'mouseup', chromUp); hiliteShow(pxDown,pxDown); return false; } }); } } function chromMove(e) { // If mouse was down, determine if dragged, then show hilite if ( mouseIsDown ) { var pxX = e.clientX - img.scrolledLeft; var relativeX = (pxX - pxDown); if (mouseHasMoved || (mouseHasMoved === false && Math.abs(relativeX) > 2)) { mouseHasMoved = true; if (isWithin(chr.left,pxX,chr.right)) hiliteShow(pxDown,pxX); else if (pxX < chr.left) hiliteShow(pxDown,chr.left); else hiliteShow(pxDown,chr.right); } } } function chromUp(e) { // If mouse was down, handle final selection $(document).off('mousemove',chromMove); $(document).off('mouseup',chromUp); chromMove(e); // Just in case if (mouseIsDown) { updateImgOffsets(); var bands; var pxUp = e.clientX - img.scrolledLeft; var pxY = e.clientY - img.scrolledTop; if (isWithin(0,pxY,img.height)) { // within vertical range or else cancel var selRange = { beg: -1, end: -1, width: -1 }; var dontAsk = true; if (e.ctrlKey) { bands = findCytoBand(pxDown,pxUp); if (bands.end > -1) { pxDown = bands.left; pxUp = bands.right; mouseHasMoved = true; dontAsk = false; selRange.beg = bands.beg; selRange.end = bands.end; hiliteShow(pxDown,pxUp); } } else if (mouseHasMoved) { // bounded by chrom dimensions: but must remain within image! if (isWithin(-20,pxUp,chr.left)) pxUp = chr.left; if (isWithin(chr.right,pxUp,img.width + 20)) pxUp = chr.right; if ( isWithin(chr.left,pxUp,chr.right+1) ) { selRange.beg = convertToBases(pxDown); selRange.end = convertToBases(pxUp); if (Math.abs(selRange.end - selRange.beg) < 20) mouseHasMoved = false; // Drag so small: treat as simple click else dontAsk = false; } } if (mouseHasMoved === false) { // Not else because small drag turns this off hiliteShow(pxUp,pxUp); var curWidth = hgTracks.winEnd - hgTracks.winStart; // Notice that beg is based upon up position selRange.beg = convertToBases(pxUp) - Math.round(curWidth/2); selRange.end = selRange.beg + curWidth; } if (selRange.end > -1) { // prompt, then submit for new position selRange = rangeNormalizeToChrom(selRange,chr); if (mouseHasMoved === false) { // Update highlight by converting bp back to pix pxDown = convertFromBases(selRange.beg); pxUp = convertFromBases(selRange.end); hiliteShow(pxDown,pxUp); } //if ((selRange.end - selRange.beg) < 50000) // dontAsk = true; if (dontAsk || confirm("Jump to new position:\n\n"+chr.name+":"+commify(selRange.beg)+ "-"+commify(selRange.end)+" size:"+commify(selRange.width)) ) { genomePos.setByCoordinates(chr.name, selRange.beg, selRange.end); // Stop the presses :0) $('area.cytoBand').on("mousedown", function(e) { return false; }); if (imageV2.backSupport) { imageV2.navigateInPlace("db=" + getDb() + "&position=" + encodeURIComponent(genomePos.get().replace(/,/g,'')) + "&findNearest=1",null,true); hiliteCancel(); } else document.TrackHeaderForm.submit(); return true; // Make sure the setTimeout below is not called. } } } hiliteCancel(); setTimeout(posting.allowMapClicks,50); } mouseIsDown = false; mouseHasMoved = false; } function isWithin(beg,here,end) { // Simple utility return ( beg <= here && here < end ); } function convertToBases(pxX) { // Simple utility to convert pix to bases var offset = (pxX - chr.left)/chr.width; if (chr.reverse) offset = 1 - offset; return Math.round(offset * chr.size); } function convertFromBases(bases) { // Simple utility to convert bases to pix var offset = bases/chr.size; if (chr.reverse) offset = 1 - offset; return Math.round(offset * chr.width) + chr.left; } function findDimensions() { // Called at init: determine the dimensions of chrom from 'cytoband' map items var lastX = -1; $('area.cytoBand').each(function(ix) { var loc = this.coords.split(","); if (loc.length === 4) { var myLeft = parseInt(loc[0]); var myRight = parseInt(loc[2]); if (chr.top === -1) { chr.left = myLeft; chr.right = myRight; chr.top = parseInt(loc[1]); chr.bottom = parseInt(loc[3]); } else { if (chr.left > myLeft) chr.left = myLeft; if (chr.right < parseInt(loc[2])) chr.right = parseInt(loc[2]); } let title = this.getAttribute("data-tooltip") || this.getAttribute("title"); var range = title.substr(title.lastIndexOf(':')+1); var pos = range.split('-'); if (pos.length === 2) { if (chr.name.length === 0) { chr.beg = parseInt(pos[0]); //chr.end = parseInt(pos[1]); chr.name = title.substring(title.lastIndexOf(' ')+1, title.lastIndexOf(':')); } else { if (chr.beg > parseInt(pos[0])) chr.beg = parseInt(pos[0]); } if (chr.end < parseInt(pos[1])) { chr.end = parseInt(pos[1]); if (lastX === -1) lastX = myRight; else if (lastX > myRight) chr.reverse = true; // end is advancing, but X is not, so reverse } else if (lastX !== -1 && lastX < myRight) chr.reverse = true; // end is not advancing, but X is, so reverse } $(this).css( 'cursor', 'text'); $(this).attr("href",""); } }); chr.size = (chr.end - chr.beg ); chr.width = (chr.right - chr.left); } function findCytoBand(pxDown,pxUp) { // Called when mouseup and ctrl: Find the bounding cytoband dimensions (in pix and bases) var cyto = { left: -1, right: -1, beg: -1, end: -1 }; $('area.cytoBand').each(function(ix) { var loc = this.coords.split(","); if (loc.length === 4) { var myLeft = parseInt(loc[0]); var myRight = parseInt(loc[2]); var range; var pos; if (cyto.left === -1 || cyto.left > myLeft) { if ( isWithin(myLeft,pxDown,myRight) || isWithin(myLeft,pxUp,myRight) ) { cyto.left = myLeft; range = this.title.substr(this.title.lastIndexOf(':')+1); pos = range.split('-'); if (pos.length === 2) { cyto.beg = (chr.reverse ? parseInt(pos[1]) : parseInt(pos[0])); } } } if (cyto.right === -1 || cyto.right < myRight) { if ( isWithin(myLeft,pxDown,myRight) || isWithin(myLeft,pxUp,myRight) ) { cyto.right = myRight; range = this.title.substr(this.title.lastIndexOf(':')+1); pos = range.split('-'); if (pos.length === 2) { cyto.end = (chr.reverse ? parseInt(pos[0]) : parseInt(pos[1])); } } } } }); return cyto; } function rangeNormalizeToChrom(selection,chrom) { // Called before presenting or using base range: make sure chrom selection // is within chrom range if (selection.end < selection.beg) { var tmp = selection.end; selection.end = selection.beg; selection.beg = tmp; } selection.width = (selection.end - selection.beg); selection.beg += 1; if (selection.beg < chrom.beg) { selection.beg = chrom.beg; selection.end = chrom.beg + selection.width; } if (selection.end > chrom.end) { selection.end = chrom.end; selection.beg = chrom.end - selection.width; if (selection.beg < chrom.beg) { // spans whole chrom selection.width = (selection.end - chrom.beg); selection.beg = chrom.beg + 1; } } return selection; } function hiliteShow(down,cur) { // Called on mousemove, mouseup: set drag hilite dimensions var topY = img.top; var high = img.height; var begX = -1; var wide = -1; if (cur < down) { begX = cur + img.left; wide = (down - cur); } else { begX = down + img.left; wide = (cur - down); } $(hilite).css({ left: begX + 'px', width: wide + 'px', top: topY + 'px', height: high + 'px', display:'' }); $(hilite).show(); } function hiliteCancel(left,width,top,height) { // Called on mouseup: Make green drag hilite disappear when no longer wanted $(hilite).hide(); $(hilite).css({ left: '0px', width: '0px', top: '0px', height: '0px' }); } function hiliteSetup() { // Called on init: setup of drag region hilite (but don't show yet) if (hilite === null) { // setup only once hilite = jQuery("
"); $(hilite).css({ backgroundColor: 'green', opacity: 0.4, borderStyle: 'solid', borderWidth: '1px', bordercolor: '#0000FF' }); $(hilite).css({ display: 'none', position: 'absolute', overflow: 'hidden', zIndex: 1 }); jQuery($(chrImg).parents('body')).append($(hilite)); } return hilite; } function updateImgOffsets() { // Called on mousedown: Gets the current offsets var offs = $(chrImg).offset(); img.top = Math.round(offs.top ); img.left = Math.round(offs.left); img.scrolledTop = img.top - $("body").scrollTop(); img.scrolledLeft = img.left - $("body").scrollLeft(); if (theClient.isIePre11()) { img.height = $(chrImg).outerHeight(); img.width = $(chrImg).outerWidth(); } else { img.height = $(chrImg).height(); img.width = $(chrImg).width(); } return img; } }); }; ////////////////////////// //// Drag Scroll code //// ////////////////////////// jQuery.fn.panImages = function(){ // globals across all panImages genomePos.original = genomePos.getOriginalPos(); // redundant but makes certain original is set. var leftLimit = hgTracks.imgBoxLeftLabel * -1; var rightLimit = (hgTracks.imgBoxPortalWidth - hgTracks.imgBoxWidth + leftLimit); var only1xScrolling = ((hgTracks.imgBoxWidth - hgTracks.imgBoxPortalWidth) === 0); var prevX = (hgTracks.imgBoxPortalOffsetX + hgTracks.imgBoxLeftLabel) * -1; var portalWidth = 0; var portalAbsoluteX = 0; var savedPosition; var highlightAreas = null; // Used to ensure dragSelect highlight will scroll. this.each(function(){ var pic; var pan; if ( $(this).is("img") ) { pan = $(this).parent("div"); pic = $(this); } else if ( $(this).is("div.scroller") ) { pan = $(this); pic = $(this).children("img#panImg"); // Get the real pic } if (!pan || !pic) { throw "Not a div with child image! 'panImages' can only be used with divs contain images."; } // globals across all panImages portalWidth = $(pan).width(); portalAbsoluteX = $(pan).offset().left; // globals to one panImage var newX = 0; var mouseDownX = 0; var mouseIsDown = false; var beyondImage = false; var atEdge = false; initialize(); function initialize(){ $(pan).parents('td.tdData').on("mousemove", function(e) { if (e.shiftKey) $(this).css('cursor',"crosshair"); // shift-dragZoom else if ( theClient.isIePre11() ) // IE will override map item cursors if this gets set $(this).css('cursor',""); // normal pointer when not over clickable item }); panAdjustHeight(prevX); pan.on("mousedown", function(e){ if (e.which > 1 || e.button > 1 || e.shiftKey || e.metaKey || e.altKey || e.ctrlKey) return true; if (mouseIsDown === false) { if (rightClick.menu) { rightClick.menu.hide(); } mouseIsDown = true; mouseDownX = e.clientX; highlightAreas = $('.highlightItem'); atEdge = (!beyondImage && (prevX >= leftLimit || prevX <= rightLimit)); $(document).on('mousemove',panner); $(document).on('mouseup', panMouseUp); // Will exec only once return false; } }); } function panner(e) { //if (!e) e = window.event; if ( mouseIsDown ) { var relativeX = (e.clientX - mouseDownX); if (relativeX !== 0) { if (posting.mapClicksAllowed()) { // need to throw up a z-index div. Wait mask? savedPosition = genomePos.get(); dragMaskShow(); posting.blockMapClicks(); } var decelerator = 1; //var wingSize = 1000; // 0 stops the scroll at the edges. // Remeber that offsetX (prevX) is negative newX = prevX + relativeX; if ( newX >= leftLimit ) { // scrolled all the way to the left if (atEdge) { // Do not drag straight off edge. Force second drag beyondImage = true; newX = leftLimit + (newX - leftLimit)/decelerator;// slower //if (newX >= leftLimit + wingSize) // Don't go too far over the edge! // newX = leftLimit + wingSize; } else newX = leftLimit; } else if ( newX < rightLimit ) { // scrolled all the way to the right if (atEdge) { // Do not drag straight off edge. Force second drag beyondImage = true; newX = rightLimit - (rightLimit - newX)/decelerator;// slower //if (newX < rightLimit - wingSize) // Don't go too far over the edge! // newX = rightLimit - wingSize; } else newX = rightLimit; } else if (newX >= rightLimit && newX < leftLimit) beyondImage = false; // could have scrolled back without mouse up posStatus = panUpdatePosition(newX,true); newX = posStatus.newX; // do not update highlights if we are at the end of a chromsome if (!posStatus.isOutsideChrom) scrollHighlight(relativeX); var nowPos = newX.toString() + "px"; $(".panImg").css( {'left': nowPos }); $('.tdData').css( {'backgroundPosition': nowPos } ); if (!only1xScrolling) panAdjustHeight(newX); // Will dynamically resize image while scrolling. } } } function panMouseUp(e) { // Must be a separate function instead of pan.mouseup event. //if (!e) e = window.event; if (mouseIsDown) { dragMaskClear(); $(document).off('mousemove',panner); $(document).off('mouseup',panMouseUp); mouseIsDown = false; // timeout incase the dragSelect.selectEnd was over a map item. select takes precedence. setTimeout(posting.allowMapClicks,50); // Outside image? Then abandon. var curY = e.pageY; var imgTbl = $('#imgTbl'); var north = $(imgTbl).offset().top; var south = north + $(imgTbl).height(); if (curY < north || curY > south) { atEdge = false; beyondImage = false; if (savedPosition) genomePos.set(savedPosition); var oldPos = prevX.toString() + "px"; $(".panImg").css( {'left': oldPos }); $('.tdData').css( {'backgroundPosition': oldPos } ); if (highlightAreas) imageV2.drawHighlights(); return true; } // Do we need to fetch anything? if (beyondImage) { if (imageV2.inPlaceUpdate) { var pos = parsePosition(genomePos.get()); imageV2.navigateInPlace("db=" + getDb() + "&position=" + encodeURIComponent(pos.chrom + ":" + pos.start + "-" + pos.end), null, true); } else { document.TrackHeaderForm.submit(); } return true; // Make sure the setTimeout below is not called. } // Just a normal scroll within a >1X image if (prevX !== newX) { prevX = newX; if (!only1xScrolling) { //panAdjustHeight(newX); // Will resize image AFTER scrolling. // Important, since AJAX could lead to reinit after this within bounds scroll hgTracks.imgBoxPortalOffsetX = (prevX * -1) - hgTracks.imgBoxLeftLabel; hgTracks.imgBoxPortalLeft = newX.toString() + "px"; } } } } }); // end of this.each(function(){ function panUpdatePosition(newOffsetX,bounded) { // Updates the 'position/search" display with change due to panning var closedPortalStart = hgTracks.imgBoxPortalStart + 1; // Correction for half open var portalWidthBases = hgTracks.imgBoxPortalEnd - closedPortalStart; var portalScrolledX = hgTracks.imgBoxPortalOffsetX+hgTracks.imgBoxLeftLabel + newOffsetX; var recalculate = false; var newPortalStart = 0; if (hgTracks.revCmplDisp) newPortalStart = closedPortalStart + // As offset goes down, so do bases seen. Math.round(portalScrolledX*hgTracks.imgBoxBasesPerPixel); else newPortalStart = closedPortalStart - // As offset goes down, bases seen goes up! Math.round(portalScrolledX*hgTracks.imgBoxBasesPerPixel); if (newPortalStart < hgTracks.chromStart && bounded) { // Stay within bounds newPortalStart = hgTracks.chromStart; recalculate = true; } var newPortalEnd = newPortalStart + portalWidthBases; if (newPortalEnd > hgTracks.chromEnd && bounded) { newPortalEnd = hgTracks.chromEnd; newPortalStart = newPortalEnd - portalWidthBases; recalculate = true; } if (newPortalStart > 0) { var newPos = hgTracks.chromName + ":" + newPortalStart + "-" + newPortalEnd; genomePos.set(newPos); // no need to change the size } if (recalculate && hgTracks.imgBoxBasesPerPixel > 0) { // Need to recalculate X for bounding drag portalScrolledX = (closedPortalStart - newPortalStart) / hgTracks.imgBoxBasesPerPixel; newOffsetX = portalScrolledX - (hgTracks.imgBoxPortalOffsetX+hgTracks.imgBoxLeftLabel); } if (typeof (igv) !== "undefined") { igv.updateIgvStartPosition(newPortalStart); } ret = {}; ret.newX = newOffsetX; ret.isOutsideChrom = recalculate; return ret; } function mapTopAndBottom(mapName,east,west) { // Find the top and bottom px given left and right boundaries var mapPortal = { top: -10, bottom: -10 }; var items = $("map[name='"+mapName+"']").children(); if ($(items).length>0) { $(items).each(function(t) { var loc = this.coords.split(","); var aleft = parseInt(loc[0]); var aright = parseInt(loc[2]); if (aleft < west && aright >= east) { var atop = parseInt(loc[1]); var abottom = parseInt(loc[3]); if (mapPortal.top < 0 ) { mapPortal.top = atop; mapPortal.bottom = abottom; } else if (mapPortal.top > atop) { mapPortal.top = atop; } else if (mapPortal.bottom < abottom) { mapPortal.bottom = abottom; } } }); } return mapPortal; } function panAdjustHeight(newOffsetX) { // Adjust the height of the track data images so that bed items scrolled off screen // do not waste vertical real estate // Is the > 1x? if (only1xScrolling) return; var east = newOffsetX * -1; var west = east + portalWidth; $(".panImg").each(function(t) { var mapid = this.id.replace('img_','map_'); var hDiv = $(this).parent(); var north = parseInt($(this).css("top")) * -1; var south = north + $(hDiv).height(); var mapPortal = mapTopAndBottom(mapid,east,west); if (mapPortal.top > 0) { var topdif = Math.abs(mapPortal.top - north); var botdif = Math.abs(mapPortal.bottom - south); if (topdif > 2 || botdif > 2) { $(hDiv).height( mapPortal.bottom - mapPortal.top ); north = mapPortal.top * -1; $(this).css( {'top': north.toString() + "px" }); // Need to adjust side label height as well! var imgId = this.id.split("_"); var titlePx = 0; var center = $("#img_center_"+imgId[2]); if (center.length > 0) { titlePx = $(center).parent().height(); north += titlePx; } var side = $("#img_side_"+imgId[2]); if (side.length > 0) { $(side).parent().height( mapPortal.bottom - mapPortal.top + titlePx); $(side).css( {'top': north.toString() + "px" }); } var btn = $("#p_btn_"+imgId[2]); if (btn.length > 0) { $(btn).height( mapPortal.bottom - mapPortal.top + titlePx); } else { btn = $("#img_btn_"+imgId[2]); if (btn.length > 0) { $(btn).parent().height( mapPortal.bottom - mapPortal.top + titlePx); $(btn).css( {'top': top.toString() + "px" }); } } } } }); dragMaskResize(); // Resizes the dragMask to match current image size } function dragMaskShow() { // Sets up the dragMask to show grabbing cursor within image // and not allowed north and south of image var imgTbl = $('#imgTbl'); // Find or create the waitMask (which masks the whole page) var dragMask = normed($('div#dragMask')); if (!dragMask) { $("body").prepend("
"); dragMask = $('div#dragMask'); } $('body').css('cursor','not-allowed'); $(dragMask).css('cursor',"url(../images/grabbing.cur),w-resize"); $(dragMask).css({opacity:0.0,display:'block', top: $(imgTbl).position().top.toString() + 'px', height: $(imgTbl).height().toString() + 'px' }); } function dragMaskResize() { // Resizes dragMask (called when image is dynamically resized in >1x scrolling) var imgTbl = $('#imgTbl'); // Find or create the waitMask (which masks the whole page) var dragMask = normed($('div#dragMask')); if (dragMask) { $(dragMask).height( $(imgTbl).height() ); } } function dragMaskClear() { // Clears the dragMask $('body').css('cursor','auto'); var dragMask = normed($('#dragMask')); if (dragMask) $(dragMask).hide(); } function scrollHighlight(relativeX) // Scrolls the highlight region if one exists { if (highlightAreas) { for (i=0; i