447513356fcfa684846bdd1b63476e68d9c17ce9
chmalee
  Thu Apr 6 09:04:41 2023 -0700
Add code to create tooltips under our control. Convert title attributes
on hgTracks into these tooltips

diff --git src/hg/js/utils.js src/hg/js/utils.js
index 8fc1bc2..bc29d86 100644
--- src/hg/js/utils.js
+++ src/hg/js/utils.js
@@ -1,21 +1,22 @@
 // Utility JavaScript
 
 // "use strict";
 
 // Don't complain about line break before '||' etc:
 /* jshint -W014 */
+/* jshint -W087 */
 /* jshint esnext: true */
 
 var debug = false;
 
 /* Support these formats for range specifiers.  Note the ()'s around chrom,
  * start and end portions for substring retrieval: */
 var canonicalRangeExp = /^[\s]*([\w._#-]+)[\s]*:[\s]*([-0-9,]+)[\s]*[-_][\s]*([0-9,]+)[\s]*$/;
 var gbrowserRangeExp =  /^[\s]*([\w._#-]+)[\s]*:[\s]*([0-9,]+)[\s]*\.\.[\s]*([0-9,]+)[\s]*$/;
 var lengthRangeExp =    /^[\s]*([\w._#-]+)[\s]*:[\s]*([0-9,]+)[\s]*\+[\s]*([0-9,]+)[\s]*$/;
 var bedRangeExp =       /^[\s]*([\w._#-]+)[\s]+([0-9,]+)[\s]+([0-9,]+)[\s]*$/;
 var sqlRangeExp =       /^[\s]*([\w._#-]+)[\s]*\|[\s]*([0-9,]+)[\s]*\|[\s]*([0-9,]+)[\s]*$/;
 var singleBaseExp =     /^[\s]*([\w._#-]+)[\s]*:[\s]*([0-9,]+)[\s]*$/;
 
 function copyToClipboard(ev) {
     /* copy a piece of text to clipboard. event.target is some DIV or SVG that is an icon. 
@@ -3367,30 +3368,31 @@
                 if ( arguments[1].toLowerCase() === "y" )
                     return (movedY > minPixels || movedY < (minPixels * -1));
             }
             else
                 minPixels = num;
         }
         return (   movedX > minPixels || movedX < (minPixels * -1)
                 || movedY > minPixels || movedY < (minPixels * -1));
     }
 };
 
   ///////////////////////////
  //// Drag Reorder Code ////
 ///////////////////////////
 var dragReorder = {
+    originalHeights: {}, // trackName: startHeight
 
     setOrder: function (table)
     {   // Sets the 'order' value for the image table after a drag reorder
         var varsToUpdate = {};
         $("tr.imgOrd").each(function (i) {
             if ($(this).attr('abbr') !== $(this).attr('rowIndex').toString()) {
                 $(this).attr('abbr',$(this).attr('rowIndex').toString());
                 var name = this.id.substring('tr_'.length) + '_imgOrd';
                 varsToUpdate[name] = $(this).attr('abbr');
             }
         });
         if (objNotEmpty(varsToUpdate)) {
             cart.setVarsObj(varsToUpdate);
             imageV2.markAsDirtyPage();
         }
@@ -3697,67 +3699,344 @@
                         }
                         elem = elem.nextSibling;
                     }
                 }
 
             }
         }
     },
 
     mapItemMouseOut: function ()
     {
         imageV2.lastTrack = rightClick.currentMapItem; // Just a backup
         rightClick.currentMapItem = null;
     },
 
+    
+
     init: function ()
     {   // Make side buttons visible (must also be called when updating rows in the imgTbl).
         var btns = $("p.btn");
         if (btns.length > 0) {
             dragReorder.zipButtons($('#imgTbl'));
             $(btns).mouseenter( dragReorder.buttonMouseOver );
             $(btns).mouseleave( dragReorder.buttonMouseOut  );
             $(btns).show();
         }
         var handle = $("td.dragHandle");
         if (handle.length > 0) {
             $(handle).mouseenter( dragReorder.dragHandleMouseOver );
             $(handle).mouseleave( dragReorder.dragHandleMouseOut  );
         }
 
         // setup mouse callbacks for the area tags
         $("#imgTbl").find("tr").mouseover( dragReorder.trMouseOver );
+        $("#imgTbl").find("tr").each( function (i, row) {
+            // save the original y positions of each row
+            //if (row.id in dragReorder.originalHeights === false) {
+                dragReorder.originalHeights[row.id] = row.getBoundingClientRect().y + window.scrollY;
+            //}
+        });
 
 
         $(".area").each( function(t) {
                             this.onmouseover = dragReorder.mapItemMouseOver;
                             this.onmouseout = dragReorder.mapItemMouseOut;
                             this.onclick = posting.mapClk;
                         });
     }
 };
 
 function trackHubSkipHubName(name) {
     // Just like hg/lib/trackHub.c's...
     var matches;
     if (name && (matches = name.match(/^hub_[0-9]+_(.*)/)) !== null) {
         return matches[1];
     } else {
         return name;
     }
 }
 
+function positionMouseover(ev, refEl, popUpEl, mouseX, mouseY) {
+    /* The actual mouseover positioning function.
+    * refEl is an already existing element with coords that we use to
+    * position popUpEl. popUpEl will try to be as close to the right/above the refEl, except when:
+    *     it would extend past the screen in which case it would go left/below appropriately.
+    *     the refEl takes up the whole screen, in which case we can cover the refEl
+    *          with no consequence */
+    if (! (refEl instanceof Element)) {
+        // not a map/area element, maybe from some other part of the UI
+        console.log("trying to place a mouseover element next to an element that has not been created yet");
+        throw new Error();
+    }
+
+    // obtain coordinates for placing the mouseover
+    let refWidth, refHeight, refX, refY, y1;
+    let refRight, refLeft, refTop, refBottom;
+    let rect;
+    let windowWidth = window.innerWidth;
+    let windowHeight = window.innerHeight;
+    if (refEl.coords !== undefined && refEl.coords.length > 0 && refEl.coords.split(",").length == 4) {
+        // if we are dealing with an <area> element, the refEl width and height
+        // are for the whole image and not for just the area, so
+        // getBoundingClientRect() will return nothing, sad!
+        let refImg = $("[usemap=#" + refEl.parentNode.name + "]")[0];
+        let refImgRect = refImg.getBoundingClientRect();
+        let refImgWidth = refImgRect.width;
+        let refImgHeight = refImgRect.height;
+        let label = $("[id^=td_side]")[0];
+        let btn = $("[id^=td_btn]")[0];
+        let labelWidth = 0, btnWidth = 0;
+        if (label && btn) {
+            labelWidth = label.getBoundingClientRect().width;
+            btnWidth = label.getBoundingClientRect().width;
+        }
+        let imgWidth = refImgWidth;
+        if (refEl.parentNode.name !== "ideoMap") {
+            imgWidth -= labelWidth - btnWidth;
+        }
+        let refImgOffset = refImgRect.y + window.scrollY; // distance from start of image to top of viewport;
+        [x1,y1,x2,y2] = refEl.coords.split(",").map(x => parseInt(x));
+        refX = x1; refY = y1;
+        refWidth = x2 - x1; refHeight = y2 - y1;
+        refRight = x2; refLeft = x1;
+        refTop = y1; refBottom = y2;
+
+        // now we need to offset our coordinates to the track tr, to account for dragReorder
+        let parent = refEl.closest(".trDraggable");
+        if (refEl.parentNode.name === "ideoMap") {
+            parent = refImg.closest("tr");
+            let currParentOffset = parent.getBoundingClientRect().y;
+            let yDiff = y1;
+            refTop = currParentOffset + yDiff;
+            refBottom = currParentOffset + yDiff + refHeight;
+        } else if (parent) {
+            // how far in y direction we are from the tr start in the original image from the server:
+            let currParentOffset = parent.getBoundingClientRect().y;
+            let yDiff = y1 - hgTracks.trackDb[parent.id.slice(3)].imgOffsetY;
+            refTop = currParentOffset + yDiff;
+            refBottom = currParentOffset + yDiff + refHeight;
+        }
+    } else {
+        rect = refEl.getBoundingClientRect();
+        refX = rect.x; refY = rect.y;
+        refWidth = rect.width; refHeight = rect.height;
+        refRight = rect.right; refLeft = rect.left;
+        refTop = rect.top; refBottom = rect.bottom;
+    }
+
+    // figure out how large the mouseover will be
+    // use firstChild because popUpEl should be a div with a sole child in it
+    let popUpRect = popUpEl.firstChild.getBoundingClientRect();
+    // position the popUp to the right and above the cursor by default
+    let topOffset = refTop - popUpRect.height;
+    let leftOffset = mouseX + 15; // add 15 for large cursor sizes
+
+    // first case, refEl takes the whole width of the image, so not a big deal to cover some of it
+    // this is most common for the track labels
+    if (mouseX + popUpRect.width >= (windowWidth - 25)) {
+        // move to the left
+        leftOffset = mouseX - popUpRect.width;
+    }
+    // or the mouse is on the right third of the screen
+    if (mouseX > (windowWidth* 0.66)) {
+        // move to the left
+        leftOffset = mouseX - popUpRect.width;
+    }
+
+    if (leftOffset < 0) {
+        throw new Error("trying to position off of screen to left");
+    }
+    popUpEl.style.left = leftOffset + "px";
+    popUpEl.style.top = topOffset + "px";
+}
+
+function replaceReserved(txt) {
+    /* This should somehow be made more general so we can stop worrying about
+     * user made tracks with whatever characters in it */
+    if (!txt) {
+        throw new Error("trying to replace null txt");
+    }
+    return txt.replace(/[^A-Za-z0-9_]/g, "_");
+}
+
+// the current mouseover timer, for showing the mouseover after a delay
+var mouseoverTimer;
+// signal handler for when mousemove has gone far enough away from the pop up
+// we can't use removeEventListener because the function call is hard to keep
+// track of because of a bounded this keyword
+var mousemoveSignal;
+// The div that moves around the users screen with the visible mouseover text
+var mouseoverContainer;
+
+function hideMouseoverText(ele) {
+    /* Actually hides the tooltip text */
+    let tooltipTarget = ele;
+    tooltipTarget.classList.remove("isShown");
+    tooltipTarget.style.opacity = "0";
+    tooltipTarget.style.visibility = "hidden";
+}
+
+function hideMouseover(e) {
+    /* a mouseover has been shown, and now the mouse has moved
+    * if the mouse is near the pop up, keep it present, else hide it */
+    refEl = e.target; // the span with the text in it
+    if (!refEl) {debugger;}
+    mouseX = e.pageX > 0 ? e.pageX: e.clientX;
+    mouseY = e.pageY > 0 ? e.pageY: e.clientY;
+    targetBox = refEl.getBoundingClientRect();
+    if ( mouseX >= (targetBox.left - 45) && mouseX <= (targetBox.right + 45) &&
+            mouseY >= (targetBox.top - 45) && mouseY <= (targetBox.bottom + 45) ) {
+        // keep the mouseover showing
+        return;
+    } else {
+        // now that we are going to hide the pop up we can remove the event listener
+        // for whether we wanted to keep the pop up or not
+        hideMouseoverText(refEl);
+        if (mousemoveSignal) {mousemoveSignal.abort();}
+    }
+}
+
+function mousemoveHelper(e) {
+    /* Helper function for deciding whether to keep a tooltip visible upon a mousemove event */
+    let targetBox = this.getBoundingClientRect();
+    let mouseX = e.clientX;
+    let mouseY = e.clientY;
+    // currently allow the mouse to move in a 45 pixel box around the element
+    if ( mouseX >= (targetBox.left - 45) && mouseX <= (targetBox.right + 45) &&
+            mouseY >= (targetBox.top - 45) && mouseY <= (targetBox.bottom + 45) ) {
+        // keep the mouseover showing
+        return;
+    } else {
+        // now that we are going to hide the pop up we can remove the event listener
+        // for whether we wanted to keep the pop up or not
+        console.log("hiding mouseover:");
+        console.log(this);
+        hideMouseoverText(this);
+        mousemoveSignal.abort();
+    }
+}
+
+function showMouseoverText(e) {
+    // actually show the mouseover text
+    e.preventDefault();
+    referenceElement = e.target;
+    if (!e.target.getAttribute("mouseoverid")) {
+        // corner case: the side slice and grey control bar slice are weird, the td
+        // container has the title tag, while the img or a element receives the mouseover
+        // event, so we need to go back up the tree to find the mouseoverid for those elems
+        while (referenceElement.parentElement && !referenceElement.getAttribute("mouseoverid")) {
+            referenceElement = referenceElement.parentElement;
+        }
+    }
+    let tooltipDivId = "#" + referenceElement.getAttribute("mouseoverid");
+    let tooltipDiv = $(tooltipDivId)[0];
+    mouseoverContainer.replaceChildren();
+    let divCpy = tooltipDiv.cloneNode(true);
+    divCpy.childNodes.forEach(function(n) {
+        mouseoverContainer.appendChild(n);
+    });
+    positionMouseover(e, referenceElement, mouseoverContainer, e.pageX, e.pageY);
+    mouseoverContainer.classList.add("isShown");
+    mouseoverContainer.style.opacity = "1";
+    mouseoverContainer.style.visibility = "visible";
+
+    // Events all get their own unique id but they are tough to keep track of if we
+    // want to remove one. We can use the new AbortController interface to let the
+    // web browser automatically raise a signal when the event is fired and remove
+    // appropriate event
+    mousemoveSignal = new AbortController();
+    let callback = mousemoveHelper.bind(mouseoverContainer);
+
+    // allow the user to mouse over the mouse over, (eg. clicking a link or selecting text)
+    document.addEventListener("mousemove", callback, {signal: mousemoveSignal.signal});
+    mouseoverTimer = undefined;
+}
+
+function showMouseover(e) {
+    /* Helper function for showing a mouseover. Uses a timeout function to allow
+     * user to not immediately see all available tooltips. */
+    e.preventDefault();
+    // if a tooltip is currently visible, we need to wait for its mouseout
+    // event to clear it before we can show this one, ie a user "hovers"
+    // this element on their way to mousing over the shown mouseover
+    if ($(".tooltip.isShown").length > 0) {
+        return;
+    }
+    if (mouseoverTimer) {
+        // user is moving their mouse around, make sure where they stop is what we show
+        clearTimeout(mouseoverTimer);
+    }
+    mouseoverTimer = setTimeout(showMouseoverText, 300, e);
+}
+
+function addMouseover(ele1, text = null, ele2 = null) {
+    /* Adds wrapper elements to control various mouseover events */
+    if (!mouseoverContainer) {
+        mouseoverContainer = document.createElement("div");
+        mouseoverContainer.className = "tooltip";
+        mouseoverContainer.style.position = "fixed";
+        mouseoverContainer.style.display = "inline-block";
+        mouseoverContainer.style.visibility = "hidden";
+        mouseoverContainer.style.opacity = "0";
+        mouseoverContainer.id = "mouseoverContainer";
+        document.body.append(mouseoverContainer);
+    }
+    // create a mouseover element out of text, or, if text is null, use already
+    // created ele2 and just show it
+    newEl = ele2;
+    if (text !== null) {
+        newEl = document.createElement("span");
+        newEl.style = "max-width: 400px"; // max width of the mouseover text
+        newEl.innerHTML = text;
+    }
+    if (ele1) {
+        newDiv = document.createElement("div");
+        newDiv.className = "tooltip";
+        newDiv.style.position = "fixed";
+        newDiv.style.display = "inline-block";
+        if (ele1.title) {
+            newDiv.id = replaceReserved(ele1.title);
+            ele1.title = "";
+        } else {
+            newDiv.id = replaceReserved(text);
+        }
+        if (ele1.coords) {
+            newDiv.id += "_" + ele1.coords.replaceAll(",","_");
+        }
+        ele1.setAttribute("mouseoverid", newDiv.id);
+        newDiv.append(newEl);
+        ele1.parentNode.append(newDiv);
+        ele1.addEventListener("mouseover", showMouseover);
+    }
+}
+
+function titleTagToMouseover(mapEl) {
+    /* for a given area tag, extract the title text into a div that can be positioned
+    * like a standard tooltip mouseover next to the item */
+    addMouseover(mapEl, mapEl.title);
+}
+
+function convertTitleTagsToMouseovers() {
+    /* make all the title tags in the ideogram or main image have mouseovers */
+    $("[name=ideoMap]>[title],#imgTbl [title]").each(function(i, a) {
+        if (a.title !== undefined && a.title.length > 0) {
+            titleTagToMouseover(a);
+        }
+    });
+}
+
 function parseUrl(url) {
     // turn a url into some of it's components like server, query-string, etc
     let protocol, serverName, pathInfo, queryString;
     let temp;
     temp = url.split("?");
     if (temp.length > 1)
         queryString = temp.slice(1).join("?");
     temp = temp[0].split("/");
     protocol = temp[0]; // "https:"
     serverName = temp[2]; // "genome-test.gi.ucsc.edu"
     pathInfo = temp.slice(3).join("/"); // "cgi-bin/hgTracks"
     return {protocol: protocol, serverName: serverName, pathInfo: pathInfo, queryString: queryString};
 }
 
 function dumpCart(seconds, skipNotification) {