d8ccab674ade18f74884056a5e020651f423d0f0
jcasper
  Mon Mar 30 08:25:10 2026 -0700
Faceted composite UI has more consistent naming (track/sample vs row),
facet counts update dynamically, and facet values are now case-insensitive
(matching behavior elsewhere, so you don't see 27 but click it and get 34
tracks).  refs #36320

diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index b974724bbc5..a34426b6653 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -146,182 +146,258 @@
         const ordinaryColumns = colNames.map(key => ({  // all but checkboxes
             data: key,
             title: toTitleCase(key.replace(/^_/, "")),
         }));
 
         const checkboxColumn = {
             data: null,
             orderable: false,
             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 singularLabel = itemLabel.slice(0, -1);
+
+        const columns = [checkboxColumn, ...ordinaryColumns];
         const table = $("#theMetaDataTable").DataTable({
             data: metadata,
             deferRender: true,    // seems faster
             columns: columns,
             columnDefs: [ { targets:0, render: DataTable.render.select() } ],
             responsive: true,
             layout: {
                 topStart: 'pageLength',
                 topEnd: null,        // omit global search
                 bottomStart: 'info',
                 bottomEnd: 'paging'
             },
             order: [[1, "asc"]],  // sort by the first data column, not checkbox
-            pageLength: 50,       // show 50 rows per page by default
+            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}`, },
+            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))" },
             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);
+            drawCallback: function() {
+                updateSelectAllCheckbox(this.api());
             },
         });
 
+        function updateSelectAllCheckbox(api) {
+            const filteredCount = api.rows({ search: "applied" }).count();
+            const selectedCount = api.rows({ search: "applied", selected: true }).count();
+            $("#select-all")
+                .prop("checked", filteredCount > 0 && selectedCount === filteredCount)
+                .prop("indeterminate", selectedCount > 0 && selectedCount < filteredCount);
+        }
+        table.on("select deselect", () => updateSelectAllCheckbox(table));
+        updateSelectAllCheckbox(table);  // set initial state after pre-selections
+
         // Create "show only selected" toggle in the toolbar
         const lengthDiv = document.querySelector(
             "#theMetaDataTable_wrapper .dt-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);
         const toggleText = Object.assign(
             document.createElement("span"), {id: "selected-filter-text"});
         toggleWrapper.appendChild(toggleText);
         lengthDiv.appendChild(toggleWrapper);
 
         function updateSelectedText() {
             const selCount = table.rows({selected: true}).count();
             const totalCount = table.rows().count();
             toggleText.textContent =
-                `Show only selected rows (${selCount} of ${totalCount} selected)`;
+                `Show only selected ${itemLabel} (${selCount} of ${totalCount} selected)`;
         }
         updateSelectedText();
         table.on("select deselect", updateSelectedText);
 
         // 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);
+        const tableEl = document.getElementById("theMetaDataTable");
+        tableEl.parentNode.insertBefore(activeFiltersDiv, tableEl);
 
         // 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.data === null) {
                 // left empty; toggle is now in the toolbar
-            } else if (col.data && col.data.startsWith("_")) {
-                // no search box for hidden-facet columns
+            } else if (col.data && col.data.startsWith("__")) {
+                // no search box for double-underscore 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();
+                const dtColIdx = $(this).parent().index();
+                const colName = colNames[dtColIdx - 1];  // offset for checkbox col
+                if (this.value) {
+                    textFilters.set(colName, this.value.toLowerCase());
+                } else {
+                    textFilters.delete(colName);
+                }
+                table.column(dtColIdx).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();
         });
         $("#selected-filter input[data-select-filter]")
             .on("change", function () { table.draw(); });
 
         // implement the 'select all' at the top of the checkbox column
+        $("#select-all").closest("label").attr(
+            "title", `Select all filtered ${itemLabel}`);
         $("#theMetaDataTable thead").on("click", "#select-all", function () {
             const rowIsChecked = this.checked;
             if (rowIsChecked) {
-                table.rows({ page: "current" }).select();
+                table.rows({ search: "applied" }).select();
             } else {
-                table.rows({ page: "current" }).deselect();
+                table.rows({ search: "applied" }).deselect();
             }
         });
         return table;
     }  // end initTable
 
 
+    // Map of colName -> Map of unescapedValue -> spanElement, for dynamic counts
+    const countSpans = new Map();
+    // Filter state for cross-facet count computation
+    const checkboxFilters = new Map();  // colName -> Set<string> (raw values)
+    const textFilters = new Map();      // colName -> lowercase string
+
+    function updateFacetCounts(metadata) {
+        // For each facet, count values among rows that pass all OTHER filters
+        // (excluding this facet's own checkbox filter). This way, unchecked
+        // values show how many rows would be added if you checked them.
+        for (const [facetCol, valMap] of countSpans) {
+            const counts = new Map();  // lowercased value -> count
+            for (const row of metadata) {
+                let passes = true;
+                for (const [col, valueSet] of checkboxFilters) {
+                    if (col === facetCol) continue;
+                    if (!valueSet.has(row[col]?.toLowerCase())) {
+                        passes = false; break;
+                    }
+                }
+                if (passes) {
+                    for (const [col, text] of textFilters) {
+                        if (!row[col]?.toLowerCase().includes(text)) {
+                            passes = false; break;
+                        }
+                    }
+                }
+                if (passes) {
+                    const val = row[facetCol]?.toLowerCase();
+                    counts.set(val, (counts.get(val) ?? 0) + 1);
+                }
+            }
+            for (const [val, span] of valMap) {
+                span.textContent = `(${counts.get(val.toLowerCase()) ?? 0})`;
+            }
+        }
+    }
+
     function initFilters(table, allData) {
         const { metadata, colorMap, colNames } = allData;
 
         // iterate once over entire data not separately per attribute
-        const possibleValues = {};
+        // Case-insensitive: merge variants, keep first-seen casing as display form
+        const possibleValues = {};  // key -> Map<lowerVal, [displayVal, count]>
         for (const entry of metadata) {
             for (const [key, val] of Object.entries(entry)) {
-                if (possibleValues[key] === null || possibleValues[key] === undefined) {
+                if (!possibleValues[key]) {
                     possibleValues[key] = new Map();
                 }
                 const map = possibleValues[key];
-                map.set(val, (map.get(val) ?? 0) + 1);
+                const lower = val.toLowerCase();
+                const existing = map.get(lower);
+                if (existing) {
+                    existing[1]++;
+                } else {
+                    map.set(lower, [val, 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, colIdx) => {
             // skip attributes if they should be excluded from checkbox sets
             if (excludeCheckboxes.includes(key) || key.startsWith("_")) {
                 return;
             }
 
-            const sortedPossibleVals = Array.from(possibleValues[key].entries());
-            sortedPossibleVals.sort((a, b) => b[1] - a[1]);  // neg: less-than
+            // possibleValues[key] is Map<lower, [displayVal, count]>; extract [displayVal, count]
+            const sortedPossibleVals = Array.from(possibleValues[key].values());
+            sortedPossibleVals.sort((a, b) => b[1] - a[1]);  // sort by count descending
 
             // Use 'maxCheckboxes' most frequent items (if they appear > 1 time)
             let topToShow = sortedPossibleVals
                 .filter(([val, count]) =>
                     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;
                 }
@@ -346,79 +422,101 @@
             });
             facetDiv.appendChild(heading);
 
             // Collapsible body: holds Clear button + all checkboxes
             const facetBody = document.createElement("div");
             facetBody.classList.add("facet-body");
 
             // 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 = [];
+            const rawValues = [];  // parallel to cboxes: unescaped values
+            if (!countSpans.has(key)) countSpans.set(key, new Map());
+            const colSpans = countSpans.get(key);
             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})`));
+                label.appendChild(document.createTextNode(`${val} `));
+                const countSpan = document.createElement("span");
+                countSpan.textContent = `(${count})`;
+                label.appendChild(countSpan);
+                colSpans.set(val, countSpan);
                 facetBody.appendChild(label);
                 cboxes.push(checkbox);
+                rawValues.push(val);
             });
 
             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 checked = cboxes.filter(c => c.checked).map(c => c.value);
                     const query = checked.length ? "^(" + checked.join("|") + ")$" : "";
+                    // Track lowercased values for cross-facet counting
+                    const checkedRaw = new Set();
+                    cboxes.forEach((c, i) => {
+                        if (c.checked) checkedRaw.add(rawValues[i].toLowerCase());
+                    });
+                    if (checkedRaw.size) {
+                        checkboxFilters.set(key, checkedRaw);
+                    } else {
+                        checkboxFilters.delete(key);
+                    }
                     table.column(dtColIdx).search(query, true, false).draw();
                     updateActiveFilters();
                 });
             });
 
             // --- Wire up Clear button ---
             clearBtn.addEventListener("click", () => {
                 cboxes.forEach(cb => cb.checked = false);
+                checkboxFilters.delete(key);
                 table.column(dtColIdx).search("", true, false).draw();
                 updateActiveFilters();
             });
         });  // done creating collapsible checkbox filters for each column
 
+        // Update facet counts whenever the table is redrawn (filtering, search, etc.)
+        table.on("draw", () => updateFacetCounts(metadata));
+
         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;
         }