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' />" + + " <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') };