4bb9e8caea515342ba98d3871da76cd4ec69916f chmalee Fri May 1 14:10:00 2026 -0700 Initial myVariants implementation: a form on hgTracks where users can enter item details in one of three ways: hgvs/item search, simple bed form, advanced bed form where additional non-bed fields can dynamically created. Allows changing the color of items, writing descriptions, and editing the items after creation. Show overlaps with hardcoded tracks when hgc page is open (not in the hgc dialog). Next commit has implementation of sharing these tracks with other users diff --git src/hg/js/hgTracks.js src/hg/js/hgTracks.js index 33a2458b56e..7a1393b7525 100644 --- src/hg/js/hgTracks.js +++ src/hg/js/hgTracks.js @@ -630,68 +630,899 @@ 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 by dragging ///// + //// Creating items ///// ///////////////////////////////////// -var makeItemsByDrag = { +var myVariants = { + createBedForm: function(dialogEle) { + // Simple fields shown by default (matches hgc edit form style) + const simpleFields = [ + { label: "Label", id: "name", type: "text", placeholder: "Optional item label", + info: "A short label for this variant, displayed in the browser." }, + { label: "Color", id: "color", type: "color" }, + { label: "Ref", id: "ref", type: "text", placeholder: "Optional reference allele sequence", + info: "Reference allele sequence at this position." }, + { label: "Alt", id: "alt", type: "text", placeholder: "Optional alternate allele sequence", + info: "Alternate (variant) allele sequence." }, + { 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 variant. Displayed on the details page." }, + { label: "Project", id: "project", type: "text", placeholder: "Optional project name", + info: "Group variants by project. Projects with no items are automatically removed from the list." }, + ]; + + // Advanced fields hidden by default + const advancedFields = [ + { 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." }, + { label: "Thick Start", id: "thickStart", type: "number", + info: "Start of thickly drawn region (for display purposes)." }, + { label: "Thick End", id: "thickEnd", type: "number", + info: "End of thickly drawn region (for display purposes)." }, + ]; + + 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"; + + // Helper to resize dialog based on current mode + let resizeDialog = function() { + var dialog = $("#myVariantsDialog"); + var hgvsVisible = document.getElementById("hgvsInputDiv").style.display !== "none"; + var advancedVisible = document.getElementById("advancedFieldsDiv") && + document.getElementById("advancedFieldsDiv").style.display !== "none"; + + if (hgvsVisible) { + // Quick input mode - compact + dialog.dialog("option", "width", 580); + dialog.dialog("option", "height", "auto"); + } else if (advancedVisible) { + // Manual mode with advanced fields - larger + dialog.dialog("option", "width", Math.min(700, window.innerWidth * 0.8)); + dialog.dialog("option", "height", Math.min(600, window.innerHeight * 0.8)); + } else { + // Manual mode without advanced fields - medium + dialog.dialog("option", "width", Math.min(650, window.innerWidth * 0.8)); + dialog.dialog("option", "height", "auto"); + } + // Re-center the dialog after resize + dialog.dialog("option", "position", { my: "center", at: "center", of: window }); + }; - end: function (img, selection) - { - var image = $(img); - var imageId = image.attr('id'); - var trackName = imageId.substring('img_data_'.length); - var pos = genomePos.selectionPixelsToBases(image, selection); - var command = document.getElementById('hgt_doJsCommand'); - command.value = "makeItems " + trackName + " " + hgTracks.chromName; - command.value += " " + pos.chromStart + " " + pos.chromEnd; - document.TrackHeaderForm.submit(); + // Helper to format and update the position summary shown in manual mode + 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.innerHTML = "<b>Position:</b> " + chrom.value + + ":" + startFmt + "-" + endFmt + " <span style='color:#888'>(from current view)</span>"; + } + }; + + let toggleForm = function(event) { + event.preventDefault(); + let hgvsInputStyle = document.getElementById("hgvsInputDiv").style.display; + let manualInputStyle = document.getElementById("manualInputDiv").style.display; + document.getElementById("hgvsInputDiv").style.display = hgvsInputStyle === "none" ? "" : "none"; + document.getElementById("manualInputDiv").style.display = manualInputStyle === "none" ? "" : "none"; + document.getElementById("hgvsManualToggle").textContent = hgvsInputStyle === "none" ? "Or edit item fields manually" : "Back to quick input mode"; + resizeDialog(); + }; + 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"; + + // Position summary line at top of manual form + 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 posSummaryText = document.createElement("span"); + posSummaryText.id = "positionSummaryText"; + posSummary.appendChild(posSummaryText); + 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"); + let advToggle = document.getElementById("advancedFieldsToggle"); + if (advDiv && advDiv.style.display === "none") { + advDiv.style.display = ""; + if (advToggle) advToggle.textContent = "Hide Advanced Fields \u25B2"; + resizeDialog(); + } + // Scroll to and focus the chrom field + let chromField = document.getElementById("chrom"); + if (chromField) { + chromField.scrollIntoView({behavior: "smooth", block: "nearest"}); + chromField.focus(); + } + }); + 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.type = "text"; + 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 variants by project. Projects with no items are automatically removed from the list.")); + container.appendChild(wrapper); + }; + + // Create simple fields + let colorInput = null; + simpleFields.forEach(field => { + // Handle project field specially + if (field.id === "project") { + createProjectField(manualInpDiv); + } else { + let input = createField(field, manualInpDiv); + if (field.id === "color") { + colorInput = input; + } + } + }); + + // Toggle for advanced fields + let advancedToggleContainer = document.createElement("p"); + let advancedToggle = document.createElement("a"); + advancedToggle.href = "#"; + advancedToggle.id = "advancedFieldsToggle"; + advancedToggle.textContent = "Show Advanced Fields ▼"; + advancedToggleContainer.appendChild(advancedToggle); + manualInpDiv.appendChild(advancedToggleContainer); + + // Advanced fields div (hidden by default) + let advancedDiv = document.createElement("div"); + advancedDiv.id = "advancedFieldsDiv"; + advancedDiv.style.display = "none"; + advancedDiv.style.marginLeft = "20px"; + advancedDiv.style.borderLeft = "2px solid #ccc"; + advancedDiv.style.paddingLeft = "10px"; + + advancedFields.forEach(field => { + createField(field, advancedDiv); + }); + + // Custom fields section inside advanced fields + 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); + + // Reserved field names that cannot be used as custom field names + let reservedNames = ["bin", "chrom", "chromStart", "chromEnd", "name", "score", + "strand", "thickStart", "thickEnd", "itemRgb", "description", "db", "ref", + "alt", "project", "mouseover", "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(); + resizeDialog(); + }) + .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(); + resizeDialog(); + }); + row.appendChild(removeBtn); + } + + customFieldsList.appendChild(row); + if (document.getElementById("myVariantsDialog")) + resizeDialog(); + 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); + } + + advancedDiv.appendChild(customFieldsSection); + + manualInpDiv.appendChild(advancedDiv); + + // Keep the position summary in sync when advanced fields change + ["chrom", "start", "end"].forEach(function(id) { + let el = advancedDiv.querySelector("#" + id); + if (el) { + el.addEventListener("input", updatePositionSummary); + } + }); + // Initialize the position summary text using direct references + // (the form is not yet in the document, so getElementById won't work) + 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.innerHTML = "<b>Position:</b> " + chromEl.value + + ":" + startFmt + "-" + endFmt + " <span style='color:#888'>(from current view)</span>"; + } + + // Toggle handler for advanced fields + advancedToggle.addEventListener("click", function(event) { + event.preventDefault(); + let advDiv = document.getElementById("advancedFieldsDiv"); + if (advDiv.style.display === "none") { + advDiv.style.display = ""; + advancedToggle.textContent = "Hide Advanced Fields ▲"; + } else { + advDiv.style.display = "none"; + advancedToggle.textContent = "Show Advanced Fields ▼"; + } + resizeDialog(); + }); + + 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" + }); + } + + return form; }, - init: function (trackName) - { - // Set up so that they can drag out to define a new item on a makeItems track. - var img = $("#img_data_" + trackName); - if (img && img.length !== 0) { - var imgHeight = imageV2.imgTbl.height(); - jQuery(img.imgAreaSelect( { selectionColor: 'green', outerColor: '', - minHeight: imgHeight, maxHeight: imgHeight, onSelectEnd: makeItemsByDrag.end, - autoHide: true, movable: false})); + init: function () { + // show a jquery-ui dialog when a user clicks on the 'make item' 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"; + msg.innerHTML = "Please <a href=\"./hgSession\">log in</a> 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 Variants", + resizable: false, + height: "auto", + width: 580, + modal: true, + closeOnEscape: true, + autoOpen: false, + buttons: dialogButtons + }); + } else { + // got here after async image update, need to update the bed form coordinates + 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; + start.value = thickStart.value = hgTracks.winStart; + end.value = thickEnd.value = hgTracks.winEnd; + // Update the position summary to reflect new coordinates + 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.innerHTML = "<b>Position:</b> " + (chromEl ? chromEl.value : hgTracks.chromName) + + ":" + startFmt + "-" + endFmt + " <span style='color:#888'>(from current view)</span>"; + } } + + // if we clicked outside of the pop up, close the popup: + document.addEventListener('click', (e) => { + let dialogEl = document.getElementById("myVariantsDialog"); + if (!dialogEl) 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"); + } + }); }, - load: function () - { - for (var id in hgTracks.trackDb) { - var rec = hgTracks.trackDb[id]; - if (rec && rec.type && rec.type.indexOf("makeItems") === 0) { - this.init(id); + 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 + const data = {}; + if (form.elements.hgvsInput.value) { + data.hgvsInput = form.elements.hgvsInput.value; + } else { + Array.from(form.elements).forEach( (ele) => { + if (ele.name === "myVariantsHgvsInput" || ele.name === "hgt_doJsCommand" || + (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; + }); + // 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; + } + } + } + + // 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 variant: " + 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("Variant 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 = "Variant created at <strong>" + posStr + "</strong>"; + content.appendChild(msg); + + // Show dialog with buttons + $(content).dialog({ + title: "Variant Created", + modal: true, + width: 450, + buttons: { + "Go to Variant": 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 = "<label><input type='checkbox' id='myVariantsRememberNav'> Remember this choice </label>"; + checkboxSpan.appendChild(createInfoIcon( + "Save your preference. Future variants 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; }, @@ -1236,31 +2067,31 @@ str2 += "</ul>\n"; if (!(hgTracks.virtualSingleChrom && (selectedRegions === 1))) { str += str2; } $("#dragSelectPosition").html(str); } else { $("#dragSelectPosition").html(newPosition); } $(dragSelectDialog).dialog({ modal: true, title: "Drag-and-select", closeOnEscape: true, resizable: false, autoOpen: false, revertToOriginalPos: true, - minWidth: 550, + minWidth: 650, buttons: { "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 { @@ -1281,30 +2112,60 @@ $(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"); }, + "Create Item": 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`), + dataType: "html", + trueSuccess: imageV2.updateImgAndMap, + success: catchErrorOrDispatch, + error: errorHandler, + cmd: 'wholeImage', + loadingId: showLoadingImage("imgTbl"), + cache: false + }); + }, "Cancel": function() { $(this).dialog("close"); } }, 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')) @@ -3839,36 +4700,37 @@ close: function() { popUpHgcOrHgGene.cleanup(); }, title: function() { this.innerHTML = titleText; } }); // override the _title function to show custom html: //$('#hgcDialog').dialog('option' , 'title', titleText); $('#hgcDialog').dialog('open'); // if there is anything on the hgc page that would normally run // on document.ready, run it now hgc.initPage(); document.addEventListener('click', e => { - // if we clicked outside of the pop up, close the popup: - mouseX = e.clientX; - mouseY = e.clientY; - popUpBox = document.getElementById("hgcDialog").parentElement.getBoundingClientRect(); - if (mouseX < popUpBox.left || mouseX > popUpBox.right || - mouseY < popUpBox.top || mouseY > popUpBox.bottom) { + // if we clicked outside of the pop up, close the popup + // Use contains() instead of coordinates because native browser + // dropdowns (select elements) render outside the dialog bounds + let dialogEl = document.getElementById("hgcDialog"); + if (!dialogEl) return; + let dialogContainer = dialogEl.parentElement; + if (dialogContainer && !dialogContainer.contains(e.target)) { $("#hgcDialog").dialog("close"); } }); // Customize message based on current mode // Make 'Cancel' button close dialog $('input[name="Cancel"]').on("click", function() { $('#hgcDialog').dialog('close'); }); } }; // Show the exported data hubs popup function showExportedDataHubsPopup() { let popUp = document.getElementById("exportedDataHubsPopup"); @@ -4448,53 +5310,56 @@ if (hgTracks.imgBoxPortal) { $("div.scroller").panImages(); } if (imageV2.backSupport) { if (id) { // The remainder is only needed for full reload imageV2.markAsDirtyPage(); // vis of cfg change imageV2.drawHighlights(); if (typeof showMouseovers !== 'undefined' && showMouseovers) { convertTitleTagsToMouseovers(); } return; } } imageV2.loadRemoteTracks(); - makeItemsByDrag.load(); imageV2.loadSuggestBox(); imageV2.drawHighlights(); if (imageV2.backSupport) { // try/catch: browser extensions that proxy history.pushState can throw // "Permission denied to access property apply" under Firefox cross-origin // security. Swallow it so zoom/drag still complete; only the URL-bar // position update is lost. try { imageV2.setInHistory(false); // Set this new position into History stack } catch (e) { console.warn("setInHistory failed, continuing:", e); } } else { imageV2.markAsDirtyPage(); } if (typeof showMouseovers !== 'undefined' && showMouseovers) { convertTitleTagsToMouseovers(); } if(typeof window.igvBrowser !== "undefined") { window.igvBrowser.search(genomePos.get()); } + + if (typeof doMyVariants !== 'undefined' && doMyVariants) { + myVariants.init(); + } }, updateImgForId: function (html, id, fullImageReload, newJsonRec) { // update row in imgTbl for given id. // return true if we successfully pull slice for id and update it in imgTrack. var newTr = $(html).find("tr[id='tr_" + id + "']"); if (newTr.length > 0) { var tr = $(document.getElementById("tr_" + id)); if (tr.length > 0) { $(tr).html(newTr.children()); // Need to update tr class list too var classes = $(html).find("tr[id='tr_"+ id + "']")[0].className; if (classes && classes.length > 0) { $(tr).removeClass(); @@ -6113,31 +6978,30 @@ // Drag scroll init if (hgTracks.imgBoxPortal) { // Turn on drag scrolling. $("div.scroller").panImages(); } // Retrieve tracks via AJAX that may take too long to draw initialliy (i.e. a remote bigWig) var retrievables = $('#imgTbl').find("tr.mustRetrieve"); if ($(retrievables).length > 0) { $(retrievables).each( function (i) { var trackName = $(this).attr('id').substring(3); imageV2.requestImgUpdate(trackName,"",""); }); } imageV2.loadRemoteTracks(); - makeItemsByDrag.load(); if (typeof clinicalTour !== 'undefined') { if (typeof startClinicalOnLoad !== 'undefined' && startClinicalOnLoad){ clinicalTour.start(); } } // show a tutorial page if this is a new user if (typeof basicTour !== 'undefined') { if (typeof startTutorialOnLoad !== 'undefined' && startTutorialOnLoad) { basicTour.start(); } let lsKey = "hgTracks_hideTutorial"; let isUserLoggedIn = (typeof userLoggedIn !== 'undefined' && userLoggedIn === true); let hideTutorial = localStorage.getItem(lsKey); let tutMsgKey = "hgTracks_tutMsgCount"; @@ -6241,30 +7105,39 @@ if (typeof showDownloadButton !== 'undefined' && showDownloadButton) { newListEl = document.createElement("li"); newLink = document.createElement("a"); newLink.setAttribute("id", "hgTracksDownload"); newLink.setAttribute("name", "downloadTracks"); newLink.textContent = "Download Current Track Data"; newLink.href = "#"; newListEl.appendChild(newLink); let opt = document.querySelector("#downloads > ul"); if (opt) { opt.appendChild(newListEl); $("#hgTracksDownload").on("click", downloadCurrentTrackData.showDownloadUi); } } + if (typeof doMyVariants !== 'undefined' && doMyVariants) { + myVariants.init(); + document.getElementById("myVariantsButton").addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + myVariants.showDialog(); + }); + } + if (typeof showMouseovers !== 'undefined' && showMouseovers) { convertTitleTagsToMouseovers(); } }); function hgtWarnTiming(maxSeconds) { /* show a dialog box if the page load time was slower than x seconds. Has buttons to hide or never show this again. */ var loadTime = window.performance.timing.domContentLoadedEventStart-window.performance.timing.navigationStart; /// in msecs var loadSeconds = loadTime/1000; if (loadSeconds < maxSeconds) return; var skipNotification = localStorage.getItem("hgTracks.hideSpeedNotification"); dumpCart(loadSeconds, skipNotification);