ef4a5ad1ea45eda4fde693f56f2106c553e87d93 chmalee Wed May 20 15:24:30 2026 -0700 Add cnv type to myVariants, refs #33808 diff --git src/hg/js/hgTracks.js src/hg/js/hgTracks.js index ba1cda170cb..97b72d6c83e 100644 --- src/hg/js/hgTracks.js +++ src/hg/js/hgTracks.js @@ -634,180 +634,259 @@ $(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) { - // Simple fields shown by default (matches hgc edit form style) - const simpleFields = [ + // 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." }, + 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." }, - { 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 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." }, + info: "Reference allele sequence at this position" }, ]; - - // Advanced fields hidden by default - const advancedFields = [ + // 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." }, + info: "1-based start position on the chromosome" }, { label: "End", id: "end", type: "number", - info: "1-based end position on the chromosome (inclusive)." }, + info: "1-based end position on the chromosome (inclusive)" }, { label: "Score", id: "score", type: "number", - info: "Score from 0-1000. Higher scores display darker." }, + 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)." }, + 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"; - // 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)); + dialog.dialog("option", "position", { my: "center", at: "center", of: window }); } else { - // Manual mode without advanced fields - medium - dialog.dialog("option", "width", Math.min(650, window.innerWidth * 0.8)); - dialog.dialog("option", "height", "auto"); + 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 }); } - // Re-center the dialog after resize - dialog.dialog("option", "position", { my: "center", at: "center", of: window }); }; - // 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 = "Position: " + chrom.value + - ":" + startFmt + "-" + endFmt + " (from current view)"; + 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(); - 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(); + 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"; - // 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 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"); - let advToggle = document.getElementById("advancedFieldsToggle"); - if (advDiv && advDiv.style.display === "none") { + if (!advDiv) return; + if (advDiv.style.display === "none") { advDiv.style.display = ""; - if (advToggle) advToggle.textContent = "Hide Advanced Fields \u25B2"; - resizeDialog(); - } - // Scroll to and focus the chrom field + 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"; @@ -927,118 +1006,177 @@ 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.")); + wrapper.appendChild(createInfoIcon("Group annotations by project. Projects with no annotations are automatically removed from the list")); container.appendChild(wrapper); }; - // Create simple fields + // 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; - simpleFields.forEach(field => { - // Handle project field specially - if (field.id === "project") { - createProjectField(manualInpDiv); - } else { + let nameWrap, colorWrap; + commonTopFields.forEach(function(field) { let input = createField(field, manualInpDiv); - if (field.id === "color") { - colorInput = input; - } - } + if (field.id === "color") { colorInput = input; colorWrap = input.parentNode; } + else if (field.id === "name") { nameWrap = input.parentNode; } }); - // 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); + // 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); - // 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"; + let posWraps = positionFields.map(function(field) { + return createField(field, advancedDiv).parentNode; + }); + manualInpDiv.appendChild(advancedDiv); - advancedFields.forEach(field => { - createField(field, advancedDiv); + let cdsBlocksDiv = document.createElement("div"); + cdsBlocksDiv.id = "cdsBlocksDiv"; + cdsBlocksDiv.style.display = "none"; + let cdsWraps = cdsFields.map(function(field) { + return createField(field, cdsBlocksDiv).parentNode; }); - // Blocks (BED12) section. Empty rows means a single full-span block. 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); - // Hidden inputs that the widget keeps in sync. createItem reads them. - 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"; + 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); - advancedDiv.appendChild(blocksSection); + 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; + } + }); - // 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"]; + "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; @@ -1102,54 +1240,50 @@ 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"; @@ -1186,68 +1320,47 @@ // 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); + manualInpDiv.appendChild(customFieldsSection); - // Keep the position summary in sync when advanced fields change + // 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); - } + 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 = "Position: " + chromEl.value + - ":" + startFmt + "-" + endFmt + " (from current view)"; - } - - // 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 ▼"; + posSummaryText.textContent = chromEl.value + ":" + startFmt + "-" + endFmt; } - 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); @@ -1275,30 +1388,32 @@ // 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"); @@ -1314,128 +1429,147 @@ 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 { - // got here after async image update, need to update the bed form coordinates + // 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; - start.value = thickStart.value = hgTracks.winStart; - end.value = thickEnd.value = hgTracks.winEnd; - // Update the position summary to reflect new coordinates + 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.innerHTML = "Position: " + (chromEl ? chromEl.value : hgTracks.chromName) + - ":" + startFmt + "-" + endFmt + " (from current view)"; + 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 - // Strict block check now (server will re-check authoritatively). - // noBlocks means the user added a row but left every size empty; - // treat as no blocks so the server synthesizes a single full-span block. + // Block validation only matters for the transcript path; SNV/CNV don't carry blocks. let blockResult = {ok: true, noBlocks: true}; - if (form.blocksWidget && form.blocksWidget.getRowCount() > 0) { + 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. - // Omit when blockCount is 0, or when the widget reported noBlocks - // (rows added but all sizes left empty); server then synthesizes - // a single full-span block. + // 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 (!bc || blockResult.noBlocks) { + 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()