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; }