630eb30bc3695afcf73a19be6e3bc9a2829b365f chmalee Wed May 13 13:04:37 2026 -0700 Make myVariants items bed12+ rather than bed9+, refs #33808 diff --git src/hg/js/myVariantsBlocks.js src/hg/js/myVariantsBlocks.js new file mode 100644 index 00000000000..1e1235ab158 --- /dev/null +++ src/hg/js/myVariantsBlocks.js @@ -0,0 +1,263 @@ +// myVariantsBlocks.js - shared row-based BED12 block editor. +// Mounted by the hgTracks "Add Annotation" dialog and by the hgc edit page. +// Authoritative validation is server-side via loadAndValidateBed; this widget +// gives quick UX feedback and keeps three hidden inputs in sync with the rows. + +/* jshint esnext: true */ +/* jshint -W014 */ +/* jshint -W087 */ +/* global document */ + +var myVariantsBlocks = (function () { + + function makeRow(offsetVal, sizeVal, onChange, onRemove) { + var row = document.createElement("div"); + row.className = "blockRow"; + row.style.cssText = "display:flex; align-items:center; gap:6px; margin-bottom:4px;"; + + var offsetInput = document.createElement("input"); + offsetInput.type = "number"; + offsetInput.min = "0"; + offsetInput.className = "blockStart"; + offsetInput.style.width = "90px"; + offsetInput.placeholder = "offset"; + if (offsetVal !== undefined && offsetVal !== null) { + offsetInput.value = offsetVal; + } + + var sizeInput = document.createElement("input"); + sizeInput.type = "number"; + sizeInput.min = "1"; + sizeInput.className = "blockSize"; + sizeInput.style.width = "90px"; + sizeInput.placeholder = "size"; + if (sizeVal !== undefined && sizeVal !== null) { + sizeInput.value = sizeVal; + } + + var removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.textContent = "x"; + removeBtn.title = "Remove this block"; + removeBtn.style.cssText = "border:none; background:none; color:#c00; font-size:18px; cursor:pointer; padding:0 4px; line-height:1;"; + + offsetInput.addEventListener("input", onChange); + sizeInput.addEventListener("input", onChange); + removeBtn.addEventListener("click", function (e) { + // Stop the click before the dialog's outside-click handler sees + // the target detached and decides we clicked outside. + e.stopPropagation(); + onRemove(); + }); + + row.appendChild(document.createTextNode("offset ")); + row.appendChild(offsetInput); + row.appendChild(document.createTextNode(" size ")); + row.appendChild(sizeInput); + row.appendChild(removeBtn); + return row; + } + + function parseRows(listEl) { + var rows = listEl.querySelectorAll(".blockRow"); + var starts = []; + var sizes = []; + for (var i = 0; i < rows.length; i++) { + var s = rows[i].querySelector(".blockStart"); + var z = rows[i].querySelector(".blockSize"); + var sv = parseInt(s.value, 10); + var zv = parseInt(z.value, 10); + starts.push(isNaN(sv) ? null : sv); + sizes.push(isNaN(zv) ? null : zv); + } + return {starts: starts, sizes: sizes, rows: rows}; + } + + // Light row-level sanity check while the user is mid-edit. Only flags + // values the user has actually typed; never complains about empty fields, + // first-offset-is-0, or last-block-reaches-end. Those structural rules + // belong in strictValidate (called explicitly at submit time). + function liveRowCheck(starts, sizes) { + for (var i = 0; i < starts.length; i++) { + if (starts[i] !== null && starts[i] < 0) { + return {ok: false, row: i, msg: "Offset must be >= 0"}; + } + if (sizes[i] !== null && sizes[i] <= 0) { + return {ok: false, row: i, msg: "Size must be > 0"}; + } + } + return {ok: true}; + } + + // Full BED12 invariants. Caller invokes this just before submit; the + // server's loadAndValidateBed is the authoritative re-check. + // Returns noBlocks:true when no row has a size filled in -- caller + // treats that as "no blocks" so the server synthesizes a single + // full-span block, matching the user's "I changed my mind" intent. + function strictValidate(starts, sizes, getStart, getEnd) { + if (starts.length === 0) { + return {ok: true, noBlocks: true}; + } + var hasAnySize = sizes.some(function (s) { return s !== null; }); + if (!hasAnySize) { + return {ok: true, noBlocks: true}; + } + var i; + for (i = 0; i < starts.length; i++) { + if (starts[i] === null || sizes[i] === null) { + return {ok: false, row: i, msg: "Both offset and size are required"}; + } + if (starts[i] < 0 || sizes[i] <= 0) { + return {ok: false, row: i, msg: "Offset must be >= 0 and size > 0"}; + } + } + var span = getEnd() - getStart(); + if (span <= 0) { + return {ok: false, row: 0, msg: "End must be greater than Start"}; + } + if (starts[0] !== 0) { + return {ok: false, row: 0, msg: "First offset must be 0"}; + } + for (i = 1; i < starts.length; i++) { + if (starts[i] < starts[i-1] + sizes[i-1]) { + return {ok: false, row: i, msg: "Blocks must be ordered and non-overlapping"}; + } + } + var last = starts.length - 1; + if (starts[last] + sizes[last] > span) { + return {ok: false, row: last, msg: "Last block runs past End"}; + } + if (starts[last] + sizes[last] !== span) { + return {ok: false, row: last, + msg: "Last block must reach End (offset + size must equal End - Start = " + span + ")"}; + } + return {ok: true}; + } + + function commaList(arr) { + return arr.map(function (n) { return n === null ? "" : String(n); }).join(","); + } + + function mount(containerOrId, opts) { + // containerOrId may be either an element or a DOM id string. + var container = (typeof containerOrId === "string") + ? document.getElementById(containerOrId) + : containerOrId; + if (!container) { + return null; + } + container.textContent = ""; + + var listDiv = document.createElement("div"); + listDiv.className = "blockListDiv"; + + var addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.textContent = "+ Add block"; + addBtn.style.cssText = "margin-top:4px; padding:3px 10px; font-size:12px; cursor:pointer;"; + + var countLabel = document.createElement("div"); + countLabel.style.cssText = "font-size:12px; color:#666; margin-top:4px;"; + + var errLabel = document.createElement("div"); + errLabel.style.cssText = "font-size:12px; color:#c00; margin-top:4px;"; + + function syncHiddens(data) { + if (opts.hiddenCountInput) { + opts.hiddenCountInput.value = data.rows.length; + } + if (opts.hiddenSizesInput) { + opts.hiddenSizesInput.value = commaList(data.sizes); + } + if (opts.hiddenStartsInput) { + opts.hiddenStartsInput.value = commaList(data.starts); + } + } + + // Live updates: sync hidden inputs and only show row-level garbage + // (negative offset, non-positive size). Structural rules wait for + // an explicit validate() call from the caller's submit handler. + function refresh() { + var data = parseRows(listDiv); + countLabel.textContent = "blockCount: " + data.rows.length + + (data.rows.length === 0 ? " (single full-span block will be stored)" : ""); + + for (var i = 0; i < data.rows.length; i++) { + data.rows[i].style.outline = ""; + } + var live = liveRowCheck(data.starts, data.sizes); + if (!live.ok && data.rows[live.row]) { + data.rows[live.row].style.outline = "1px solid #c00"; + } + errLabel.textContent = live.ok ? "" : live.msg; + syncHiddens(data); + } + + // Strict pre-submit validation. Returns {ok, msg}; highlights the + // offending row and shows the message. Server is still the + // authoritative validator. + function validate() { + var data = parseRows(listDiv); + for (var i = 0; i < data.rows.length; i++) { + data.rows[i].style.outline = ""; + } + var result = strictValidate(data.starts, data.sizes, + opts.getStart, opts.getEnd); + if (!result.ok && data.rows[result.row]) { + data.rows[result.row].style.outline = "1px solid #c00"; + } + errLabel.textContent = result.ok ? "" : result.msg; + syncHiddens(data); + return result; + } + + function addRow(offsetVal, sizeVal) { + // First row's offset is always 0 per BED12 spec; pre-fill it so + // the user doesn't have to think about that constraint. + if (offsetVal === undefined && + listDiv.querySelectorAll(".blockRow").length === 0) { + offsetVal = 0; + } + var row = makeRow(offsetVal, sizeVal, refresh, function () { + listDiv.removeChild(row); + refresh(); + }); + listDiv.appendChild(row); + refresh(); + } + + addBtn.addEventListener("click", function () { addRow(); }); + + container.appendChild(listDiv); + container.appendChild(addBtn); + container.appendChild(countLabel); + container.appendChild(errLabel); + + if (opts.initialSizes && opts.initialStarts && + opts.initialSizes.length === opts.initialStarts.length) { + for (var i = 0; i < opts.initialSizes.length; i++) { + addRow(opts.initialStarts[i], opts.initialSizes[i]); + } + } + refresh(); + + function clear() { + while (listDiv.firstChild) { + listDiv.removeChild(listDiv.firstChild); + } + refresh(); + } + + return { + addRow: addRow, + refresh: refresh, + validate: validate, + clear: clear, + getRowCount: function () { + return listDiv.querySelectorAll(".blockRow").length; + } + }; + } + + return { mount: mount }; +})();