630eb30bc3695afcf73a19be6e3bc9a2829b365f chmalee Wed May 13 13:04:37 2026 -0700 Make myVariants items bed12+ rather than bed9+, refs #33808 diff --git src/hg/js/hgTracks.js src/hg/js/hgTracks.js index 44c60720167..2b1259d1692 100644 --- src/hg/js/hgTracks.js +++ src/hg/js/hgTracks.js @@ -966,30 +966,61 @@ 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); }); + // 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"; + blocksSection.appendChild(hCount); + blocksSection.appendChild(hSizes); + blocksSection.appendChild(hStarts); + advancedDiv.appendChild(blocksSection); + // 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 @@ -1225,30 +1256,49 @@ // 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" }); } + // Mount the block editor; reads Start/End from the chrom range fields. + // Pass the container element directly: at this point the form is in + // the dialog div but the dialog hasn't been appended to body yet, so + // getElementById would still miss "blocksContainer". + // 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 + }); + } + 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"); @@ -1263,67 +1313,90 @@ // extract the form elements and check 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 + buttons: dialogButtons, + 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 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; + // 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. + let blockResult = {ok: true, noBlocks: true}; + if (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 { 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(); } @@ -1333,30 +1406,44 @@ // 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. + let bc = parseInt(data.blockCount, 10); + if (!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() 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" })