403ab7c9c2b204f487bb2f86260ffdab355e9517
jcasper
  Wed Aug 19 05:49:25 2026 -0700
Faceted composites should apply the active sort order to the tracks being
displayed; changing the sort changes the display order.  We also preserve that order when
returning to the page.  refs #36320

diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index b6796214e5e..f19ff0657bf 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -1,23 +1,33 @@
 // SPDX-License-Identifier: MIT; (c) 2025 Andrew D Smith (author)
 /* jshint esversion: 11 */
 $(function() {
     /* ADS: Uncomment below to force confirm on unload/reload */
     // window.addEventListener("beforeunload", function (e) {
     //     e.preventDefault(); e.returnValue = ""; });
     const DEFAULT_MAX_CHECKBOXES = 20;  // ADS: without default, can get crazy
 
+    // Hover help for the sort note above the table.  addMouseover() in utils.js
+    // renders this with innerHTML, so simple tags are fine.
+    const SORT_ORDER_HELP =
+        "The row order of this table sets the order the subtracks appear in the " +
+        "Genome Browser image.<br><br>" +
+        "Click a column heading to sort by that column; click it again to reverse " +
+        "the direction.<br><br>" +
+        "To sort on more than one column, click the first heading, then " +
+        "shift-click each additional heading, in the order you want them applied.";
+
     const isValidColorMap = obj =>  // check the whole thing and ignore if invalid
           typeof obj === "object" && obj !== null && !Array.isArray(obj) &&
           Object.values(obj).every(x =>
               typeof x === "object" && x !== null && !Array.isArray(x) &&
                   Object.values(x).every(value => typeof value === "string"));
 
     // fetch file dynamically
     const loadOptional = (url, hgsid, track) =>  { // load if possible otherwise carry on
         if (!url) return Promise.resolve(null);
         let fetchBody = `fileUrl=${url}&track=${track}`;
         if (hgsid !== null) {
             fetchBody = fetchBody + `&hgsid=${hgsid}`;
         }
         const fetchUrl = `/cgi-bin/hgTrackUi?${fetchBody}`;
         const req = (fetchUrl.length > 2048 || embeddedData.udcTimeout) ?
@@ -134,40 +144,52 @@
         const dataTag = document.getElementById("app-data");
         return dataTag ? JSON.parse(dataTag.innerText) : "";
     })();
 
     // Store initial checkbox states for delta computation on server
     const initialState = {
         dataElements: new Set(),
         dataTypes: new Set()
     };
 
     function generateHTML() {
         const container = document.createElement("div");
         container.id = "myTag";
         container.innerHTML = `
         <div id="dataTypeSelector"></div>
+        <div id="sortNote" class="smallText"></div>
         <div id="container">
             <div id="filters"></div>
             <table id="theMetaDataTable">
                 <thead></thead>
                 <tfoot></tfoot>
             </table>
         </div>
         `;
         // Instead of appending to body, append into the placeholder div
         document.getElementById("metadata-placeholder").appendChild(container);
+
+        // The table's row order drives the order tracks are drawn in the browser
+        // image.  That's easy to miss (the classic composite UI never says so
+        // either), so state it in one line and put the details in the hover.
+        // The icon is appended as a node because createInfoIcon() returns an
+        // element that already has its mouseover listeners attached.
+        const note = document.getElementById("sortNote");
+        note.appendChild(document.createTextNode(
+            "Tracks appear in the Genome Browser in the same order as the table " +
+            "below - click a column heading to re-sort."));
+        note.appendChild(createInfoIcon(SORT_ORDER_HELP));
     }
 
     function updateVisibilities(uriForUpdate, submitBtnEvent) {
         // get query params from URL
         const paramsFromUrl = new URLSearchParams(window.location.search);
         const db = paramsFromUrl.get("db");
         const hgsid = paramsFromUrl.get("hgsid");
         let body = `${uriForUpdate}`;
         if (db !== null) {
             body = body + `&db=${db}`;
         }
         if (hgsid !== null) {
             body = body + `&hgsid=${hgsid}`;
         }
         fetch("/cgi-bin/cartDump", {
@@ -269,58 +291,86 @@
             orderable: false,
             defaultContent: "",
             title: `
             <label title="Select all visible rows">
             <input type="checkbox" id="select-all"/></label>`,
             // no render function needed
         };
 
         const hasDataTypes = embeddedData.dataTypes &&
                              Object.keys(embeddedData.dataTypes).length > 0;
         const itemLabel = hasDataTypes ? "samples" : "tracks";
         const singularLabel = itemLabel.slice(0, -1);
 
         const columns = [checkboxColumn, ...ordinaryColumns];
 
+        // Map a metadata field name to its DataTables column index, matching
+        // case-insensitively and ignoring leading underscores.  Returns -1 when
+        // the name isn't one of the metadata columns.
+        const colIdxForName = name => {
+            const target = name.replace(/^_+/, "").toLowerCase();
+            const idx = colNames.findIndex(
+                c => c.replace(/^_+/, "").toLowerCase() === target);
+            return idx >= 0 ? idx + 1 : -1;  // +1 for the checkbox column
+        };
+
         // Determine which column to sort by: use defaultSortField if it matches
-        // a metadata column (case-insensitive, ignoring leading underscores),
-        // otherwise fall back to the first data column.
+        // a metadata column, otherwise fall back to the first data column.
         let defaultSortCol = 1;  // column 0 is checkboxes, 1 is first data col
         if (embeddedData.defaultSortField) {
-            const target = embeddedData.defaultSortField.replace(/^_+/, "").toLowerCase();
-            const idx = colNames.findIndex(
-                c => c.replace(/^_+/, "").toLowerCase() === target);
-            if (idx >= 0)
-                defaultSortCol = idx + 1;  // +1 for the checkbox column
+            const idx = colIdxForName(embeddedData.defaultSortField);
+            if (idx > 0)
+                defaultSortCol = idx;
+        }
+
+        // A sort the user established on an earlier visit wins over
+        // defaultSortField.  facetSortOrder mirrors the classic composite
+        // '<track>.sortOrder' cart value: 'field=+ field2=-', in sort precedence
+        // order.  Fields no longer present in the metadata are dropped, so a
+        // changed metadata file degrades to a partial (or default) sort rather
+        // than an error.
+        let initialOrder = [[defaultSortCol, "asc"]];
+        if (embeddedData.facetSortOrder) {
+            const savedOrder = embeddedData.facetSortOrder.trim().split(/\s+/)
+                .map(token => {
+                    const eq = token.lastIndexOf("=");
+                    if (eq < 1) return null;
+                    const idx = colIdxForName(token.slice(0, eq));
+                    if (idx < 0) return null;
+                    return [idx, token.slice(eq + 1) === "-" ? "desc" : "asc"];
+                })
+                .filter(Boolean);
+            if (savedOrder.length > 0)
+                initialOrder = savedOrder;
         }
 
         const table = $("#theMetaDataTable").DataTable({
             data: metadata,
             deferRender: true,    // seems faster
             columns: columns,
             columnDefs: [ { targets:0, render: DataTable.render.select() } ],
             // 'responsive' (collapsing overflow columns) is intentionally off:
             // a wide table instead gets an internal horizontal scrollbar via
             // the .table-xscroll wrapper added at the end of initTable.
             responsive: false,
             layout: {
                 topStart: 'pageLength',
                 topEnd: null,        // omit global search
                 bottomStart: 'info',
                 bottomEnd: 'paging'
             },
-            order: [[defaultSortCol, "asc"]],
+            order: initialOrder,
             pageLength: 25,       // show 25 rows per page by default
             lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]],
             language: {
                 lengthMenu: `Show _MENU_ ${itemLabel}`,
                 select: {
                     rows: {
                         0: "",
                         1: `1 ${singularLabel} selected`,
                         _: `%d ${itemLabel} selected`
                     }
                 },
                 info: `Showing _START_ to _END_ of _TOTAL_ ${itemLabel}`,
                 infoFiltered: `(filtered from _MAX_ total ${itemLabel})`,
             },
             select: { style: "multi", selector: "td:not(:has(a))" },
@@ -758,54 +808,62 @@
                 removeBtn.className = "remove-chip";
                 removeBtn.type = "button";
                 removeBtn.textContent = "\u00d7";
                 removeBtn.addEventListener("click", () => {
                     checkbox.checked = false;
                     checkbox.dispatchEvent(new Event("change"));
                 });
                 chip.appendChild(removeBtn);
                 container.appendChild(chip);
             });
         });
 
         container.style.display = "flex";
     }
 
-    function initSubmit(table) {  // logic for the submit event
+    function initSubmit(table, allData) {  // logic for the submit event
+        const { colNames } = allData;
         const { mdid, primaryKey } = embeddedData;  // mdid: metadata identifier
         const hasDataTypes = embeddedData.dataTypes &&
                              Object.keys(embeddedData.dataTypes).length > 0;
         document.getElementById("Submit").addEventListener("click", (submitBtnEvent) => {
             submitBtnEvent.preventDefault();  // hold the submit button event
 
             const currentDataTypes = [];
             if (hasDataTypes) {
                 // Get current data type selections
                 document.querySelectorAll("input.cbgroup").forEach(cb => {
                     if (cb.checked) {
                         currentDataTypes.push(cb.value);
                     }
                 });
                 // Require at least one data type when the selector exists
                 if (currentDataTypes.length === 0) {
                     alert("Please select at least one data type.");
                     return;  // abort submission
                 }
             }
 
-            // Get current data element selections
-            const currentDataElements = table.rows({selected: true}).data().toArray()
+            // Get current data element selections, in the order they are
+            // currently sorted/displayed in the table. The server uses this
+            // order to assign each shown subtrack a '.priority' cart value so
+            // the tracks appear in hgTracks in the same order as here. 'search'
+            // is 'none' so selected-but-facet-filtered rows still get a sensible
+            // position rather than being dropped from the ordering.
+            const currentDataElements =
+                table.rows({selected: true, order: "current", search: "none"})
+                    .data().toArray()
                     .map(obj => primaryKeyId(obj[primaryKey]));
 
             // Enforce an upper bound on the number of tracks on at the same time.
             // This is imperfect when data types are present - some combinations might
             // have been manually hidden by the user.  But it should be a good ballpark.
             const trackLimit = 1000;
             if (hasDataTypes) {
                 if (currentDataTypes.length * currentDataElements.length > trackLimit) {
                     alert("You have turned on too many subtracks (over 1000) - please uncheck some.");
                     return;  // abort submission
                 }
             } else {
                 if (currentDataElements.length > trackLimit) {
                     alert("You have turned on too many subtracks (over 1000) - please uncheck some.");
                     return;  // abort submission
@@ -837,39 +895,55 @@
                 if (initialState.dataTypes.size > 0) {
                     initialState.dataTypes.forEach(dt => {
                         uriForUpdate.append(`${mdid}.dt_was`, dt);});
                 } else {
                     uriForUpdate.append(`${mdid}.dt_was`, "");
                 }
                 if (currentDataTypes.length > 0) {
                     currentDataTypes.forEach(dt => {
                         uriForUpdate.append(`${mdid}.dt_now`, dt);});
                 } else {
                     uriForUpdate.append(`${mdid}.dt_now`, "");
                 }
             }
             // No ${mdid}.dt* variables indicates that the composite doesn't use data types
 
+            // Preserve the current sort so the table comes back the same way on the
+            // next visit.  Column names rather than DataTables column indexes, so
+            // this survives a change in metadata column order - the same reason
+            // defaultSortField is matched by name.  Format mirrors the classic
+            // composite '<track>.sortOrder' cart value: 'field=+ field2=-'.
+            const sortSpec = table.order()
+                // column 0 is the checkboxes and isn't orderable; an entry with
+                // neither direction is a column in its unsorted state
+                .filter(o => Array.isArray(o) && o[0] >= 1 && o[0] <= colNames.length &&
+                             (o[1] === "asc" || o[1] === "desc"))
+                .map(o => colNames[o[0] - 1] + (o[1] === "asc" ? "=+" : "=-"))
+                // whitespace in a field name would break the space-separated format
+                .filter(token => !/\s/.test(token));
+            // Sent even when empty, so the server clears any stale value
+            uriForUpdate.append(`${mdid}.facetSortOrder`, sortSpec.join(" "));
+
             updateVisibilities(uriForUpdate, submitBtnEvent);
         });
     }  // end initSubmit
 
     function initAll(dataForTable) {
         initDataTypeSelector();
         const table = initTable(dataForTable);
         initFilters(table, dataForTable);
-        initSubmit(table);
+        initSubmit(table, dataForTable);
         hideLoading();  // table is built and drawn; remove the spinner
     }
 
     function loadDataAndInit() {  // load data and call init functions
         const { mdid, primaryKey, metadataUrl, colorSettingsUrl, track } = embeddedData;
 
         const paramsFromUrl = new URLSearchParams(window.location.search);
         const hgsid = paramsFromUrl.get("hgsid");
         let fetchBody = `fileUrl=${metadataUrl}&track=${track}`;
         if (hgsid !== null) {
             fetchBody = fetchBody + `&hgsid=${hgsid}`;
         }
 
         // fetch file dynamically
         const fetchUrl = "/cgi-bin/hgTrackUi?" + fetchBody;