abd7278ac7167ede325d8c144a35ea59a0798766
chmalee
  Wed Jun 17 03:55:47 2026 -0700
Add a right click option to change color or background highlight color of individual track items. Only works for bed like items, refs #37778

diff --git src/hg/js/hgTracks.js src/hg/js/hgTracks.js
index e88593ba63d..7a52b221a80 100644
--- src/hg/js/hgTracks.js
+++ src/hg/js/hgTracks.js
@@ -3585,30 +3585,44 @@
                     });
         } else if (cmd === 'hgTrackUi_popup') {
 
             // Launches the popup but shields the ajax with a waitOnFunction
             popUp.hgTrackUi( rightClick.selectedMenuItem.id, false );  
 
         } else if (cmd === 'hgTrackUi_popup_description') {
 
             // Launches the popup but shields the ajax with a waitOnFunction
             popUp.hgTrackUi( rightClick.selectedMenuItem.id, true );
 
         } else if (cmd === 'changeTrackColor') {
 
             rightClick.showColorPicker(id);
 
+        } else if (cmd === 'colorThisItem') {
+
+            rightClick.colorThisItem();
+
+        } else if (cmd === 'removeItemColor') {
+
+            var itemToRemove = rightClick.itemFromHref(rightClick.selectedMenuItem.href);
+            if (itemToRemove)
+                rightClick.removeItemColor(itemToRemove.track, itemToRemove.name);
+
+        } else if (cmd === 'clearItemColors') {
+
+            rightClick.clearItemColors();
+
         } else if (cmd === 'hgTrackUi_follow') {
 
             url = "hgTrackUi?hgsid=" + getHgsid() + "&g=";
             rec = hgTracks.trackDb[id];
             if (tdbHasParent(rec) && tdbIsLeaf(rec))
                 url += rec.parentTrack;
             else {
                 // The button already has the ref
                 var link = normed($( 'td#td_btn_'+ rightClick.selectedMenuItem.id ).children('a')); 
                 if (link)
                     url = $(link).attr('href');
                 else
                     url += rightClick.selectedMenuItem.id;
             }
             location.assign(url);
@@ -3930,30 +3944,272 @@
             minWidth: 400,
             buttons: {
                 "Apply": function() { applyColor(); },
                 "Ok": function() {
                     if (applyColor())
                         $(this).dialog("close");
                 }
             },
             close: function() {
                 $("#trackColorPicker").spectrum("destroy");
                 $(this).remove();
             }
         });
     },
 
+    // ---- Per-item color (right-click "Color this item") ----
+    // The itemColors cart variable holds db#track#mode#itemName#hexColor records joined by '|',
+    // where mode is "item" (recolor the glyph) or "bg" (background highlight). db, track and mode
+    // are the leading fields and the color is the last '#' field, so item names containing '#'
+    // are tolerated.
+
+    getItemColors: function ()
+    {   // Current itemColors cart string, cached and seeded from the page's hgTracks json.
+        if (rightClick.itemColorsCache === undefined) {
+            rightClick.itemColorsCache =
+                (typeof hgTracks !== "undefined" && typeof hgTracks.itemColors === "string") ?
+                hgTracks.itemColors : "";
+        }
+        return rightClick.itemColorsCache;
+    },
+
+    parseItemColors: function (str)
+    {   // Parse the itemColors string into an array of {db, track, mode, name, hex} records.
+        var records = [];
+        if (!str)
+            return records;
+        str.split("|").forEach(function (rec) {
+            if (!rec)
+                return;
+            var firstHash = rec.indexOf("#");
+            var secondHash = rec.indexOf("#", firstHash + 1);
+            var thirdHash = rec.indexOf("#", secondHash + 1);
+            var lastHash = rec.lastIndexOf("#");
+            if (firstHash < 0 || secondHash < 0 || thirdHash < 0 || lastHash <= thirdHash)
+                return;
+            records.push({
+                db:    rec.substring(0, firstHash),
+                track: rec.substring(firstHash + 1, secondHash),
+                mode:  rec.substring(secondHash + 1, thirdHash),
+                name:  rec.substring(thirdHash + 1, lastHash),
+                hex:   rec.substring(lastHash + 1)   // bare hex, no leading '#'
+            });
+        });
+        return records;
+    },
+
+    serializeItemColors: function (records)
+    {
+        return records.map(function (r) {
+            return r.db + "#" + r.track + "#" + r.mode + "#" + r.name + "#" + r.hex;
+        }).join("|");
+    },
+
+    itemFromHref: function (href)
+    {   // Identify the clicked item from its hgc/hgGene link. Use the real name when it has a usable
+        // one (name field of the returned object), otherwise fall back to genomic position
+        // ("pos:chrom:start-end"). Nameless items (e.g. bed3) all share a placeholder name, so
+        // position - which both the link (c=/o=/t=) and the draw code can produce - identifies them.
+        if (!href)
+            return null;
+        var track, name, m;
+        m = /[&?]g=([^&]+)/.exec(href);
+        if (m && m[1])
+            track = decodeURIComponent(m[1]);
+        m = /[&?]i=([^&]+)/.exec(href);
+        if (m && m[1])
+            name = decodeURIComponent(m[1].replace(/\+/g, " "));
+        if (!name) {   // knownGene-style links
+            m = /[&?]hgg_gene=([^&]+)/.exec(href);
+            if (m && m[1])
+                name = decodeURIComponent(m[1]);
+            if (!track) {
+                m = /[&?]hgg_type=([^&]+)/.exec(href);
+                if (m && m[1])
+                    track = decodeURIComponent(m[1]);
+            }
+        }
+        if (!track)
+            return null;
+        // Custom tracks prefix the bed file path before the item name ("<path> <name>") and use the
+        // literal "NoItemName" when the row has no name; treat empty or "NoItemName" as nameless.
+        var nameless = !name || /(^|\s)NoItemName$/.test(name);
+        if (!nameless)
+            return {track: track, name: name};
+        // o=/t= are 0-based start/end, matching tg->itemStart/itemEnd in the draw code
+        var chrom, start, end;
+        m = /[&?]c=([^&]+)/.exec(href);
+        if (m && m[1]) chrom = decodeURIComponent(m[1]);
+        m = /[&?]o=([^&]+)/.exec(href);
+        if (m && m[1]) start = m[1];
+        m = /[&?]t=([^&]+)/.exec(href);
+        if (m && m[1]) end = m[1];
+        if (chrom && start && end)
+            return {track: track, name: "pos:" + chrom + ":" + start + "-" + end};
+        return null;
+    },
+
+    findItemColor: function (track, name)
+    {   // Return {hex (with leading '#'), mode} stored for this item, or null.
+        var db = getDb();
+        var records = rightClick.parseItemColors(rightClick.getItemColors());
+        for (var i = 0; i < records.length; i++) {
+            if (records[i].db === db && records[i].track === track && records[i].name === name)
+                return {hex: "#" + records[i].hex, mode: records[i].mode};
+        }
+        return null;
+    },
+
+    updateItemColorsVar: function (newStr)
+    {   // Persist the itemColors cart variable and keep the local cache in sync.
+        rightClick.itemColorsCache = newStr;
+        if (typeof hgTracks !== "undefined" && hgTracks)
+            hgTracks.itemColors = newStr;
+        cart.setVars(["itemColors"], [newStr], null, false);
+    },
+
+    setItemColor: function (track, name, hexWithHash, mode)
+    {
+        var db = getDb();
+        var hex = hexWithHash.replace(/^#/, "");
+        var records = rightClick.parseItemColors(rightClick.getItemColors());
+        var found = false;
+        for (var i = 0; i < records.length; i++) {
+            if (records[i].db === db && records[i].track === track && records[i].name === name) {
+                records[i].hex = hex;
+                records[i].mode = mode;
+                found = true;
+                break;
+            }
+        }
+        if (!found)
+            records.push({db: db, track: track, mode: mode, name: name, hex: hex});
+        rightClick.updateItemColorsVar(rightClick.serializeItemColors(records));
+        imageV2.requestImgUpdate(track,
+            "itemColors=" + encodeURIComponent(rightClick.getItemColors()));
+    },
+
+    removeItemColor: function (track, name)
+    {
+        var db = getDb();
+        var records = rightClick.parseItemColors(rightClick.getItemColors()).filter(function (r) {
+            return !(r.db === db && r.track === track && r.name === name);
+        });
+        rightClick.updateItemColorsVar(rightClick.serializeItemColors(records));
+        imageV2.requestImgUpdate(track,
+            "itemColors=" + encodeURIComponent(rightClick.getItemColors()));
+    },
+
+    hasItemColorsForCurrentDb: function ()
+    {   // Are any item colors set for the database currently shown?
+        var db = getDb();
+        return rightClick.parseItemColors(rightClick.getItemColors()).some(function (r) {
+            return r.db === db;
+        });
+    },
+
+    clearItemColors: function ()
+    {   // Clear item colors for the current database only, leaving other assemblies' colors alone.
+        var db = getDb();
+        var records = rightClick.parseItemColors(rightClick.getItemColors()).filter(function (r) {
+            return r.db !== db;
+        });
+        var newStr = rightClick.serializeItemColors(records);
+        rightClick.updateItemColorsVar(newStr);
+        imageV2.fullReload("itemColors=" + encodeURIComponent(newStr));
+    },
+
+    colorThisItem: function ()
+    {   // Open the per-item color dialog for the right-clicked item.
+        var sel = rightClick.selectedMenuItem;
+        var item = sel ? rightClick.itemFromHref(sel.href) : null;
+        if (!item) {
+            warn("Couldn't identify the item to color.");
+            return;
+        }
+        var existing = rightClick.findItemColor(item.track, item.name);
+        var currentColor = existing ? existing.hex : "#ff0000";
+        var currentMode = existing ? existing.mode : "item";
+        rightClick.showItemColorPicker(item.track, item.name, currentColor, currentMode);
+    },
+
+    showItemColorPicker: function (track, itemName, currentColor, currentMode)
+    {   // Spectrum dialog to recolor a single item's glyph or draw a colored background behind it.
+        var dialogId = "itemColorDialog";
+        $("#" + dialogId).remove();
+        var $dlg = $("<div>").attr("id", dialogId).html(
+            "<p>Pick a color for <b></b>:</p>" +
+            "<input type='text' id='itemColorText' size='8' />" +
+            "&nbsp;<input id='itemColorPicker' />" +
+            "<br><br><label><input type='radio' name='itemColorMode' value='item'> " +
+            "Color whole item</label>" +
+            "<br><label><input type='radio' name='itemColorMode' value='bg'> " +
+            "Background highlight</label>");
+        $dlg.find("p b").text(itemName);
+        $dlg.find("#itemColorText").val(currentColor);
+        $dlg.find("input[name='itemColorMode'][value='" +
+                  (currentMode === "bg" ? "bg" : "item") + "']").prop("checked", true);
+        $("body").append($dlg);
+        var hexColorRe = /^#[0-9a-fA-F]{6}$/;
+        $("#itemColorPicker").spectrum({
+            color: currentColor,
+            showPalette: true,
+            showSelectionPalette: true,
+            showInitial: true,
+            showInput: true,
+            preferredFormat: "hex",
+            localStorageKey: "genomebrowser",
+            hideAfterPaletteSelect: true,
+            change: function(color) {
+                $("#itemColorText").val(color.toHexString());
+            }
+        });
+        $("#itemColorText").on("change", function() {
+            var val = $(this).val();
+            if (hexColorRe.test(val))
+                $("#itemColorPicker").spectrum("set", val);
+        });
+        var applyColor = function() {
+            var color = $("#itemColorText").val();
+            if (!hexColorRe.test(color)) {
+                warn("Invalid color '" + color + "'. Expected hex format like #1a2b3c.");
+                return false;
+            }
+            var mode = $("input[name='itemColorMode']:checked").val() || "item";
+            rightClick.setItemColor(track, itemName, color, mode);
+            return true;
+        };
+        $("#" + dialogId).dialog({
+            modal: true,
+            title: "Color this item",
+            closeOnEscape: true,
+            resizable: false,
+            minWidth: 400,
+            buttons: {
+                "Apply": function() { applyColor(); },
+                "Ok": function() {
+                    if (applyColor())
+                        $(this).dialog("close");
+                }
+            },
+            close: function() {
+                $("#itemColorPicker").spectrum("destroy");
+                $(this).remove();
+            }
+        });
+    },
+
     // CGIs now use HTML tags, e.g. "<b>Transcript:</b> ENST00000297261.7<br><b>Strand:</b>"
     mouseOverToLabel: function(title)
     {
         if (title.search(/<b>Transcript: ?<[/]b>/) !== -1) {
             title = title.split("<br>")[0].split("</b>")[1];
         }
         // for older UCSC genes tracks, the protein name is forced onto the item name
         if (title.search(/&hgg_prot=/) !== -1) {
             title = title.split("&hgg_prot=")[0];
         }
         return title;
     },
 
     // when "exonNumbers on", the mouse over text is not a good item description for the right-click menu
     // "exonNumbers on" is the default for genePred/bigGenePred tracks but can also be actived for bigBed and others
@@ -4159,30 +4415,51 @@
 
                         if (displayItemFunctions) {
                             o[rightClick.makeImgTag("magnify.png") + " Zoom to " +  title] = {
                                 onclick: function(menuItemClicked, menuObject) {
                                             rightClick.hit(menuItemClicked, menuObject,
                                                     "selectWholeGene"); return true;
                                           }
                                 };
                             o[rightClick.makeImgTag("highlight.png") + " Highlight " + title] =
                                 {   onclick: function(menuItemClicked, menuObject) {
                                         rightClick.hit(menuItemClicked, menuObject,
                                                        "highlightItem");
                                         return true;
                                     }
                                 };
+                            var itemForColor = rightClick.itemFromHref(href);
+                            if (itemForColor) {
+                                o[rightClick.makeImgTag("palette.png") + " Color " + title + "..."] =
+                                    {   onclick: function(menuItemClicked, menuObject) {
+                                            rightClick.hit(menuItemClicked, menuObject,
+                                                           "colorThisItem");
+                                            return true;
+                                        }
+                                    };
+                                if (rightClick.findItemColor(itemForColor.track,
+                                                             itemForColor.name)) {
+                                    o[rightClick.makeImgTag("palette.png") +
+                                            " Remove color from " + title] =
+                                        {   onclick: function(menuItemClicked, menuObject) {
+                                                rightClick.hit(menuItemClicked, menuObject,
+                                                               "removeItemColor");
+                                                return true;
+                                            }
+                                        };
+                                }
+                            }
                             //o[rightClick.makeImgTag("highlight.png") + " Highlight THIS item"] = 
                             //    {   onclick: function(menuItemClicked, menuObject) {
                             //            rightClick.hit(menuItemClicked, menuObject,
                             //                           "highlightThisItem"); 
                             //            return true;
                             //        }
                             //    };
                             if (rightClick.supportZoomCodon &&
                                     (rec.type.indexOf("genePred") !== -1 || rec.type.indexOf("bigGenePred") !== -1)) {
                                 // http://hgwdev-larrym.gi.ucsc.edu/cgi-bin/hgGene?hgg_gene=uc003tqk.2&hgg_prot=P00533&hgg_chrom=chr7&hgg_start=55086724&hgg_end=55275030&hgg_type=knownGene&db=hg19&c=chr7
                                 var name, table;
                                 var reg = new RegExp("hgg_gene=([^&]+)");
                                 let a = reg.exec(href);
                                 if (a && a[1]) {
                                     name = a[1];
@@ -4375,30 +4652,37 @@
                 }
 
 		o[rightClick.makeImgTag("book.png")+" Track Description "+rec.shortLabel] = {
 		    onclick: function(menuItemClicked, menuObject) {
 			rightClick.hit(menuItemClicked, menuObject, "hgTrackUi_popup_description");
 			return true; }
 		    };
 
                 if (rec.defaultColor) {
                     o[rightClick.makeImgTag("palette.png")+" Change Track Color"] = {
                         onclick: function(menuItemClicked, menuObject) {
                             rightClick.hit(menuItemClicked, menuObject, "changeTrackColor");
                             return true; }
                     };
                 }
+                if (rightClick.hasItemColorsForCurrentDb()) {
+                    o[rightClick.makeImgTag("palette.png")+" Clear all item colors"] = {
+                        onclick: function(menuItemClicked, menuObject) {
+                            rightClick.hit(menuItemClicked, menuObject, "clearItemColors");
+                            return true; }
+                    };
+                }
 
                 menu.push($.contextMenu.separator);
                 menu.push(o);
             }
 
             menu.push($.contextMenu.separator);
             if (hgTracks.highlight && rightClick.clickedHighlightIdx!==null) {
                 var currentlySeen = ($('#highlightItem').length > 0); 
                 o = {};
                 // Jumps to highlight when not currently seen in image
                 var text = (currentlySeen ? " Zoom" : " Jump") + " to highlight";
                 o[rightClick.makeImgTag("highlightZoom.png") + text] = {
                     onclick: rightClick.makeHitCallback('jumpToHighlight')
                 };