d543bdd5f9407b7373c73a3cab930c389971287b
jcasper
  Mon Mar 23 09:27:15 2026 -0700
Ongoing faceted composite modifications - collapsible facets, chips for selected facets,
moving the show-only-selected element.  Also first pass at using a CGI to fetch the metadata file, refs #36320

diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index f67cf572547..4074198a754 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -9,33 +9,37 @@
     // ADS: need "matching" versions for the plugins
     const DATATABLES_URL = "../js/dataTables-1.13.6.min.js";
     const DATATABLES_SELECT_URL = "../js/dataTables.select-1.7.0.min.js";
     const CSS_URLS = [
         "../style/dataTables-1.13.6.min.css",  // dataTables CSS
         "../style/dataTables.select-1.7.0.min.css",  // dataTables Select CSS
         "../style/facetedComposite.css",  // Local metadata table CSS
     ];
 
     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"));
 
-    const loadOptional = url =>  // load if possible otherwise carry on
+    const loadOptional = (url, hgsid, track) =>  // load if possible otherwise carry on
           url ?
-          fetch(url).then(r => r.ok ? r.json() : null).catch(() => null)
+          fetch("/cgi-bin/hgFetch", {
+              method: "POST",
+              headers: { "Content-Type": "application/x-www-form-urlencoded" },
+              body: `hgsid=${hgsid}&fileUrl=${url}&track=${track}`,
+          }).then(r => r.ok ? r.json() : null).catch(() => null)
           : Promise.resolve(null);
 
     const loadIfMissing = (condition, url, callback) =>  // for missing plugins
           condition ?
           document.head.appendChild(Object.assign(
               document.createElement("script"), { src: url, onload: callback }))
           : callback();
 
     const toTitleCase = str =>
           str.toLowerCase()
           .split(/[_\s-]+/)  // Split on underscore, space, or hyphen
           .map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
 
     const escapeRegex = str => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 
@@ -110,245 +114,334 @@
                 .map(([key]) => key)
         );
         // initialize data type checkboxes (using class instead of 'name')
         document.querySelectorAll("input.cbgroup")
             .forEach(cb => { cb.checked = selectedDataTypes.has(cb.value); });
 
         // Capture initial data type state
         initialState.dataTypes = new Set(selectedDataTypes);
     }
 
     function initTable(allData) {
         const { metadata, rowToIdx, colNames } = allData;
 
         const ordinaryColumns = colNames.map(key => ({  // all but checkboxes
             data: key,
-            title: toTitleCase(key),
+            title: toTitleCase(key.replace(/^_/, "")),
         }));
 
         const checkboxColumn = {
             data: null,
             orderable: false,
             className: "select-checkbox",
             defaultContent: "",
             title: `
             <label title="Select all visible rows">
             <input type="checkbox" id="select-all"/></label>`,
             // no render function needed
         };
 
         const columns = [checkboxColumn, ...ordinaryColumns];
         const hasDataTypes = embeddedData.dataTypes && 
                              Object.keys(embeddedData.dataTypes).length > 0;
         const itemLabel = hasDataTypes ? "samples" : "tracks";
         const table = $("#theMetaDataTable").DataTable({
             data: metadata,
             deferRender: true,    // seems faster
             columns: columns,
             responsive: true,
+            dom: "lrtip",         // omit 'f' (global search); per-column inputs suffice
             // autoWidth: true,   // might help columns shrink to fit content
             order: [[1, "asc"]],  // sort by the first data column, not checkbox
             pageLength: 50,       // show 50 rows per page by default
             lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]],
             language: { lengthMenu: `Show _MENU_ ${itemLabel}`, },
-            select: { style: "multi", selector: "td:first-child" },
+            select: { style: "multi", selector: "td:not(:has(a))" },
             initComplete: function() {  // Check appropriate boxes
                 const api = this.api();
                 embeddedData.dataElements.forEach(rowName => {
                     const rowIndex = rowToIdx[rowName];
                     if (rowIndex !== undefined) {
                         api.row(rowIndex).select();
                     }
                 });
                 // Capture initial data element state
                 initialState.dataElements = new Set(embeddedData.dataElements);
             },
             drawCallback: function() {  // Reset header "select all" checkbox
                 $("#select-all")
                     .prop("checked", false)
                     .prop("indeterminate", false);
             },
         });
 
+        // Create "show only selected" toggle in the toolbar
+        const lengthDiv = document.querySelector(
+            "#theMetaDataTable_wrapper .dataTables_length");
+        const toggleWrapper = document.createElement("div");
+        toggleWrapper.id = "selected-filter";
+        const toggleLabel = document.createElement("label");
+        toggleLabel.classList.add("toggle-switch");
+        const toggleCheckbox = document.createElement("input");
+        toggleCheckbox.type = "checkbox";
+        toggleCheckbox.dataset.selectFilter = "true";
+        toggleLabel.appendChild(toggleCheckbox);
+        toggleLabel.appendChild(Object.assign(
+            document.createElement("span"), {className: "toggle-slider"}));
+        toggleWrapper.appendChild(toggleLabel);
+        toggleWrapper.appendChild(Object.assign(
+            document.createElement("span"), {textContent: "Show only selected rows"}));
+        lengthDiv.appendChild(toggleWrapper);
+
+        // Create active-filters chip bar (hidden when empty)
+        const activeFiltersDiv = document.createElement("div");
+        activeFiltersDiv.id = "active-filters";
+        activeFiltersDiv.style.display = "none";
+        const wrapper = document.getElementById("theMetaDataTable_wrapper");
+        wrapper.insertBefore(activeFiltersDiv, lengthDiv.nextSibling);
+
         // define inputs for search functionality for each column in the table
         const row = document.querySelector("#theMetaDataTable thead").insertRow();
         columns.forEach((col) => {
             const cell = row.insertCell();
-            if (col.className === "select-checkbox") {  // show selected items
-                const label = document.createElement("label");
-                label.title = "Show only selected rows";
-
-                const checkbox = document.createElement("input");
-                checkbox.type = "checkbox";
-                checkbox.dataset.selectFilter = "true";
-
-                label.appendChild(checkbox);
-                cell.appendChild(label);
+            if (col.className === "select-checkbox") {
+                // left empty; toggle is now in the toolbar
+            } else if (col.data && col.data.startsWith("_")) {
+                // no search box for hidden-facet columns
             } else {
                 const input = document.createElement("input");
                 input.type = "text";
                 input.placeholder = "Search...";
                 input.style.width = "100%";
                 cell.appendChild(input);
             }
         });
 
         // behaviors for the column-based search functionality
         $("#theMetaDataTable thead input[type='text']")
             .on("keyup change", function () {
                 table.column($(this).parent().index()).search(this.value).draw();
             });
         $.fn.dataTable.ext.search.push(function (_, data, dataIndex) {
             const filterInput =
                   document.querySelector("input[data-select-filter]");
             if (!filterInput?.checked) {  // If checkbox not checked, show all rows
                 return true;
             }
             // Otherwise, only show selected rows
             const row = table.row(dataIndex);
             return row.select && row.selected();
         });
-        $("#theMetaDataTable thead input[data-select-filter]")
+        $("#selected-filter input[data-select-filter]")
             .on("change", function () { table.draw(); });
 
         // implement the 'select all' at the top of the checkbox column
         $("#theMetaDataTable thead").on("click", "#select-all", function () {
             const rowIsChecked = this.checked;
             if (rowIsChecked) {
                 table.rows({ page: "current" }).select();
             } else {
                 table.rows({ page: "current" }).deselect();
             }
         });
         return table;
     }  // end initTable
 
+
     function initFilters(table, allData) {
         const { metadata, colorMap, colNames } = allData;
 
         // iterate once over entire data not separately per attribute
         const possibleValues = {};
         for (const entry of metadata) {
             for (const [key, val] of Object.entries(entry)) {
                 if (possibleValues[key] === null || possibleValues[key] === undefined) {
                     possibleValues[key] = new Map();
                 }
                 const map = possibleValues[key];
                 map.set(val, (map.get(val) ?? 0) + 1);
             }
         }
 
         let { maxCheckboxes, primaryKey } = embeddedData;
         if (maxCheckboxes === null || maxCheckboxes === undefined) {
             maxCheckboxes = DEFAULT_MAX_CHECKBOXES;
         }
         const excludeCheckboxes = [primaryKey];
 
         const filtersDiv = document.getElementById("filters");
-        colNames.forEach((key) => {
+        colNames.forEach((key, colIdx) => {
             // skip attributes if they should be excluded from checkbox sets
-            if (excludeCheckboxes.includes(key)) {
+            if (excludeCheckboxes.includes(key) || key.startsWith("_")) {
                 return;
             }
 
             const sortedPossibleVals = Array.from(possibleValues[key].entries());
-            sortedPossibleVals.sort((a, b) => // neg: less-than
-                 a[1] !== b[1] ? b[1] - a[1] : a[0].localeCompare(b[0]));
+            sortedPossibleVals.sort((a, b) => b[1] - a[1]);  // neg: less-than
 
             // Use 'maxCheckboxes' most frequent items (if they appear > 1 time)
             let topToShow = sortedPossibleVals
                 .filter(([val, count]) =>
-                    val.trim().toUpperCase() !== "NA")  // why only > 1?
-                    //val.trim().toUpperCase() !== "NA" && count > 1)
+                    val.trim().toUpperCase() !== "NA" && count > 1)
                 .slice(0, maxCheckboxes);
 
             // Any "other/Other/OTHER" entry will be put at the end
             let otherKey = null, otherValue = null;
             topToShow = topToShow.filter(([val, value]) => {
                 if (val.toLowerCase() === "other") {
                     otherKey = val;
                     otherValue = value;
                     return false;
                 }
                 return true;
             });
             if (otherValue !== null) {
                 topToShow.push([otherKey, otherValue]);
             }
             if (topToShow.length <= 1) {  // no point if there's only one group
                 excludeCheckboxes.push(key);
                 return;
             }
 
-            // Add headings and filter checkboxes (only top maxCheckboxes)
-            const cbSetsDiv = document.createElement("div");
-            cbSetsDiv.appendChild(Object.assign(document.createElement("strong"), {
-                textContent: toTitleCase(key)
-            }));
+            // --- Build the facet group with collapsible structure ---
+            const facetDiv = document.createElement("div");
+            facetDiv.classList.add("facet-group");
+
+            // Clickable heading that toggles collapse
+            const heading = Object.assign(document.createElement("strong"), {
+                textContent: toTitleCase(key),
+                className: "facet-heading collapsed",
+            });
+            facetDiv.appendChild(heading);
+
+            // Collapsible body: holds Clear button + all checkboxes
+            const facetBody = document.createElement("div");
+            facetBody.classList.add("facet-body", "collapsed");
+
+            // Clear button — built here so it lives inside the collapsible body
+            const clearBtn = document.createElement("button");
+            clearBtn.textContent = "Clear";
+            clearBtn.type = "button";
+            facetBody.appendChild(clearBtn);
+
+            // Build checkbox labels
+            const cboxes = [];
             topToShow.forEach(([val, count]) => {
                 const label = document.createElement("label");
                 const checkbox = document.createElement("input");
                 checkbox.type = "checkbox";
                 checkbox.value = escapeRegex(val);
                 label.appendChild(checkbox);
                 if (colorMap && key in colorMap) {
                     const colorBox = document.createElement("span");
                     colorBox.classList.add("color-box");
                     if (val in colorMap[key]) {
                         colorBox.style.backgroundColor = colorMap[key][val];
                     }
                     label.appendChild(colorBox);
                 }
                 label.appendChild(document.createTextNode(`${val} (${count})`));
-                cbSetsDiv.appendChild(label);
+                facetBody.appendChild(label);
+                cboxes.push(checkbox);
             });
-            filtersDiv.appendChild(cbSetsDiv);
-        });  // done creating checkbox filters for each column
-
-        const checkboxAttributeIndexes =  // checkbox sets => cols in data table
-              colNames.reduce((acc, key, colIdx) => {
-                  if (!excludeCheckboxes.includes(key)) { acc.push(colIdx); }
-                  return acc;
-              }, []);
-        // Now do logic to implement checkboxes-based rows display
-        checkboxAttributeIndexes.forEach((colIdx, idx) => {
-            const attrDiv = filtersDiv.children[idx];
-            const cboxes = [  // need this to be 'array' to use 'filter'
-                ...attrDiv.querySelectorAll("input[type=checkbox]")
-            ];
-            cboxes.forEach(cb => {  // Add set of checkboxes for this attribute
+
+            facetDiv.appendChild(facetBody);
+            filtersDiv.appendChild(facetDiv);
+
+            // --- Wire up collapse toggle ---
+            heading.addEventListener("click", () => {
+                const isCollapsed = facetBody.classList.toggle("collapsed");
+                heading.classList.toggle("collapsed", isCollapsed);
+            });
+
+            // --- Wire up checkbox filtering (same logic as before) ---
+            // colIdx is the 0-based index into colNames; DataTable column is
+            // colIdx + 1 because column 0 is the select-checkbox column.
+            const dtColIdx = colIdx + 1;
+            cboxes.forEach(cb => {
                 cb.addEventListener("change", () => {
-                    const chk = cboxes.filter(c => c.checked).map(c => c.value);
-                    const query = chk.length ? "^(" + chk.join("|") + ")$" : "";
-                    table.column(colIdx + 1).search(query, true, false).draw();
+                    const checked = cboxes.filter(c => c.checked).map(c => c.value);
+                    const query = checked.length ? "^(" + checked.join("|") + ")$" : "";
+                    table.column(dtColIdx).search(query, true, false).draw();
+                    updateActiveFilters();
                 });
             });
-            // Make a "clear" button
-            const clearBtn = document.createElement("button");
-            clearBtn.textContent = "Clear";
-            clearBtn.type = "button";  // prevent form submit if inside a form
+
+            // --- Wire up Clear button ---
             clearBtn.addEventListener("click", () => {
-                cboxes.forEach(cb => cb.checked = false);  // Uncheck all
-                // Recalculate the (now cleared) search term and update table
-                table.column(colIdx + 1).search("", true, false).draw();
-            });
-            // Prepend the "clear" button
-            attrDiv.insertBefore(clearBtn, attrDiv.children[1] || null);
+                cboxes.forEach(cb => cb.checked = false);
+                table.column(dtColIdx).search("", true, false).draw();
+                updateActiveFilters();
             });
+        });  // done creating collapsible checkbox filters for each column
+
         return table;  // to chain calls
     }  // end initFilters
 
+    function updateActiveFilters() {
+        const container = document.getElementById("active-filters");
+        if (!container) return;
+        container.innerHTML = "";
+
+        const checked = document.querySelectorAll(
+            "#filters input[type='checkbox']:checked");
+        if (checked.length === 0) {
+            container.style.display = "none";
+            return;
+        }
+
+        // Group by facet name
+        const groups = new Map();
+        checked.forEach(cb => {
+            const facetGroup = cb.closest(".facet-group");
+            if (!facetGroup) return;
+            const heading = facetGroup.querySelector(".facet-heading");
+            if (!heading) return;
+            const facetName = heading.textContent.trim();
+            // Get the display text from the label (strip the count suffix)
+            const label = cb.parentElement;
+            const labelText = label.textContent.trim();
+            if (!groups.has(facetName)) groups.set(facetName, []);
+            groups.get(facetName).push({ labelText, checkbox: cb });
+        });
+
+        groups.forEach((chips, facetName) => {
+            const groupLabel = document.createElement("span");
+            groupLabel.className = "filter-chip-group-label";
+            groupLabel.textContent = facetName + ":";
+            container.appendChild(groupLabel);
+
+            chips.forEach(({ labelText, checkbox }) => {
+                const chip = document.createElement("span");
+                chip.className = "filter-chip";
+                chip.appendChild(document.createTextNode(labelText + " "));
+                const removeBtn = document.createElement("button");
+                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
         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);
                     }
                 });
@@ -416,40 +509,52 @@
             }
             // No ${mdid}.dt* variables indicates that the composite doesn't use data types
 
             updateVisibilities(uriForUpdate, submitBtnEvent);
         });
     }  // end initSubmit
 
     function initAll(dataForTable) {
         initDataTypeSelector();
         const table = initTable(dataForTable);
         initFilters(table, dataForTable);
         initSubmit(table);
     }
 
     function loadDataAndInit() {  // load data and call init functions
-        const { mdid, primaryKey, metadataUrl, colorSettingsUrl } = embeddedData;
-        fetch(metadataUrl)
+        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("/cgi-bin/hgFetch", {
+            method: "POST",
+            headers: { "Content-Type": "application/x-www-form-urlencoded" },
+            body: fetchBody,
+        })
             .then(response => {
                 if (!response.ok) {  // a 404 will look like plain text
                     throw new Error(`HTTP Status: ${response.status}`);
                 }
                 return response.text();
             })
             .then(tsvText => {  // metadata table is a TSV file to parse
-                loadOptional(colorSettingsUrl).then(colorMap => {
+                loadOptional(colorSettingsUrl, hgsid, track).then(colorMap => {
                     const rows = tsvText.trim().split("\n");
                     const colNames = rows[0].split("\t");
                     const metadata = rows.slice(1).map(row => {
                         const values = row.split("\t");
                         const obj = {};
                         colNames.forEach((attrib, i) => { obj[attrib] = values[i]; });
                         return obj;
                     });
                     const rowToIdx = Object.fromEntries(
                         metadata.map((row, i) => [row[primaryKey], i])
                     );
                     colorMap = isValidColorMap(colorMap) ? colorMap : null;
                     const freshData = { metadata, rowToIdx, colNames, colorMap };
 
                     initAll(freshData);