b678946c7d246ae01d552242ac0296b8694d5fc3 max Tue Sep 8 06:15:43 2026 -0700 hgTrackUi: faceted composite says "Samples" when data types are on, and stops paging a short table The two filter tabs were hardcoded to "All Tracks" and "Active Tracks", which is wrong for a composite that uses dataTypes: there a row is a sample, standing for as many tracks as there are active data types. The file already had an itemLabel that resolves to "samples" or "tracks" and was feeding the DataTables strings, so the tabs now use the same variable. A composite without dataTypes reads exactly as before. Page length was a flat 25, so a table of 41 samples hid a third of itself behind a pager for no good reason. Tables under 50 rows now start out showing everything; the length menu still offers 10/25/50/100/All for bigger ones. refs #36210 diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js index b2d2f8448ce..4d33f92d011 100644 --- src/hg/js/facetedComposite.js +++ src/hg/js/facetedComposite.js @@ -1,1022 +1,1030 @@ // 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) ? fetch("/cgi-bin/hgTrackUi", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: fetchBody, }) : fetch(fetchUrl, { method: "GET", headers: { "Content-Type": "application/x-www-form-urlencoded" }, }); return req.then(r => r.ok ? r.json() : null).catch(() => null); }; const showLoading = () => { // spinner shown during fetch + table build if (document.getElementById("faceted-loading")) return; const el = document.createElement("div"); el.id = "faceted-loading"; el.innerHTML = `<div class="faceted-spinner"></div><div>Loading metadata…</div>`; document.getElementById("metadata-placeholder").appendChild(el); }; const hideLoading = () => { const el = document.getElementById("faceted-loading"); if (el) el.remove(); }; const toTitleStyle = str => str.replace(/_+/g, " "); const escapeRegex = str => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // For primaryKey values that use the 'id|label' form, return just the id. // The label is for display only; the cart and rowToIdx need the bare id. const primaryKeyId = v => { if (v == null) return v; const s = String(v); const bar = s.indexOf("|"); return bar >= 0 ? s.slice(0, bar) : s; }; // Split a TSV row on tabs, respecting double- or single-quoted fields. function parseTsvRow(str) { const fields = []; let i = 0, n = str.length, start = 0, inQuote = false, q = ''; while (i < n) { if (inQuote) { if (str[i] === q) { if (i + 1 < n && str[i + 1] === q) { i += 2; continue; } // escaped quote inQuote = false; } i++; } else if (str[i] === '"' || str[i] === "'") { q = str[i]; inQuote = true; i++; } else if (str[i] === '\t') { fields.push(str.slice(start, i)); i++; start = i; } else { i++; } } fields.push(str.slice(start)); return fields; } // Split a cell value on commas, respecting double- or single-quoted substrings. // Returns the trimmed, non-empty tokens. function parseCsvValues(str) { if (!str) return []; const tokens = []; let i = 0, n = str.length, start = 0, inQuote = false, q = ''; while (i < n) { if (inQuote) { if (str[i] === q) { if (i + 1 < n && str[i + 1] === q) { i += 2; continue; } inQuote = false; } i++; } else if (str[i] === '"' || str[i] === "'") { q = str[i]; inQuote = true; i++; } else if (str[i] === ',') { tokens.push(str.slice(start, i).trim()); i++; start = i; } else { i++; } } tokens.push(str.slice(start).trim()); return tokens.filter(Boolean); } // Parse one CSV token into {id, label}. // Format: \w+(\|label)? where label may be a quoted string. // label is null when no | is present; id is used for display in that case. function parseValue(token) { const bar = token.indexOf('|'); if (bar < 0) return { id: token.trim(), label: null }; const id = token.slice(0, bar).trim(); let label = token.slice(bar + 1); if (label.length >= 2) { const f = label[0], l = label[label.length - 1]; if ((f === '"' && l === '"') || (f === "'" && l === "'")) label = label.slice(1, -1); } return { id, label }; } // Return the lowercased ids parsed from a cell value string. function parseCellIds(val) { return parseCsvValues(String(val ?? "")).map(tok => parseValue(tok).id.toLowerCase()); } const embeddedData = (() => { // get data that was embedded in the HTML here to use them as globals 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="container"> <div id="filters"></div> <div id="tableColumn"> <div id="sortNote" class="smallText"></div> <table id="theMetaDataTable"> <thead></thead> <tfoot></tfoot> </table> </div> </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", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body, }).then(() => { // 'disable' any CSS named elements here to them keep out of cart const dtLength = submitBtnEvent. target.form.querySelector("select[name$='_length']"); if (dtLength) { dtLength.disabled = true; } submitBtnEvent.target.form.submit(); // release submit event }); } function initDataTypeSelector() { // Skip if no dataTypes defined or empty object if (!embeddedData.dataTypes || Object.keys(embeddedData.dataTypes).length === 0) { return; } const selector = document.getElementById("dataTypeSelector"); selector.appendChild(Object.assign(document.createElement("label"), { innerHTML: "<b>Subtrack types enabled:</b>", })); selector.appendChild(createInfoIcon( "Multiple types of data can be displayed for each of the samples listed below. " + "Check the boxes for the types you wish to view.")); Object.keys(embeddedData.dataTypes).forEach(name => { const label = document.createElement("label"); const dataType = embeddedData.dataTypes[name]; label.innerHTML = ` <input type="checkbox" class="cbgroup" value="${name}">${dataType.title}`; selector.appendChild(label); }); const selectedDataTypes = new Set( // get dataTypes selected initially Object.entries(embeddedData.dataTypes).filter(([_, val]) => val.active === 1) .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; // Match subtrackUrls trackDb keys against metadata column names // ignoring leading underscores on either side, so authors can toggle // facet visibility by adding/removing a '_' prefix in the metadata // file without having to re-edit trackDb. const stripUnderscores = s => s.replace(/^_+/, ""); const subtrackUrls = Object.fromEntries( Object.entries(embeddedData.subtrackUrls || {}) .map(([k, v]) => [stripUnderscores(k), v]) ); const ordinaryColumns = colNames.map(key => { const col = { data: key, title: toTitleStyle(key.replace(/^_+/, "")), }; const urlTemplate = subtrackUrls[stripUnderscores(key)]; if (urlTemplate) { // Mirrors hgc/hgc.c:printIdOrLinks(): split cell on ',', each // token may be 'id|label' (id substitutes $$, label is shown). // urlTemplate is html-encoded server-side (htmlEncode in // hgTrackUi.c), so it's safe to interpolate into an href. col.render = (data, type) => { if (type !== "display") return data; if (data == null || data === "") return ""; const parts = parseCsvValues(String(data)); if (!parts.length) return String(data); return parts.map(tok => { const { id, label } = parseValue(tok); const displayLabel = label !== null ? label : id; const encode = label === null && !/^https?:/i.test(displayLabel); const sub = encode ? encodeURIComponent(id) : id; const href = urlTemplate.replace(/\$\$/g, sub); return `<a href="${href}" target="_blank">${displayLabel}</a>`; }).join(", "); }; } else { col.render = (data, type) => { if (type !== "display") return data; if (data == null || data === "") return data; return parseCsvValues(String(data)) .map(tok => { const {id, label} = parseValue(tok); return label ?? id; }) .join(", "); }; } return col; }); 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 hasDataTypes = embeddedData.dataTypes && Object.keys(embeddedData.dataTypes).length > 0; const itemLabel = hasDataTypes ? "samples" : "tracks"; const singularLabel = itemLabel.slice(0, -1); + // Capitalized, for the two filter tabs. With data types a row is a + // sample rather than a track, since each row stands for as many tracks + // as there are active data types. + const itemLabelCap = itemLabel.charAt(0).toUpperCase() + itemLabel.slice(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, otherwise fall back to the first data column. let defaultSortCol = 1; // column 0 is checkboxes, 1 is first data col if (embeddedData.defaultSortField) { 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: initialOrder, - pageLength: 25, // show 25 rows per page by default + // Paginating a table that would nearly fit anyway just hides rows + // behind a menu, so show everything up to the first menu step that + // exceeds 25. -1 is what DataTables reads as "all", the same value + // behind the "All" entry in the menu below. + pageLength: metadata.length < 50 ? -1 : 25, 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))" }, 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() { 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); } // Find the Display Mode dropdown rendered by C code const visDropdown = document.querySelector( 'select[name="' + embeddedData.track + '"]'); // Track preferred non-hide visibility for auto-restore let preferredVis = "full"; if (visDropdown && visDropdown.value !== "hide") { preferredVis = visDropdown.value; } // Track previous selection count for detecting 0<->nonzero transitions let prevSelCount = table.rows({selected: true}).count(); // Update preferredVis when user manually changes the dropdown if (visDropdown) { visDropdown.addEventListener("change", function() { if (this.value !== "hide") { preferredVis = this.value; } }); } updateSelectAllCheckbox(table); // set initial state after pre-selections // Create "All / Selected" segmented tabs in the toolbar. These are a // re-skin of a simple on/off selection filter: a hidden checkbox holds // the filter state so the search-filter plug-in and selection handlers // below can stay unchanged; the tabs just drive that checkbox. const lengthDiv = document.querySelector( "#theMetaDataTable_wrapper .dt-length"); const toggleWrapper = document.createElement("div"); toggleWrapper.id = "selected-filter"; const toggleCheckbox = document.createElement("input"); toggleCheckbox.type = "checkbox"; toggleCheckbox.dataset.selectFilter = "true"; toggleCheckbox.style.display = "none"; toggleWrapper.appendChild(toggleCheckbox); const allTab = Object.assign(document.createElement("button"), {type: "button", className: "filter-tab"}); const selectedTab = Object.assign(document.createElement("button"), {type: "button", className: "filter-tab"}); toggleWrapper.appendChild(allTab); toggleWrapper.appendChild(selectedTab); lengthDiv.appendChild(toggleWrapper); // Refresh the tab labels and the active-tab highlight. Counts are grand // totals (default search:'none'), independent of the facet/search // filters, so "Selected" never misleadingly reads 0 when tracks are // selected but currently hidden by a facet. How many rows are actually // visible is reported by DataTables' bottom info line. function updateSelectedText() { const selCount = table.rows({selected: true}).count(); const totalCount = table.rows().count(); - allTab.textContent = `All Tracks (${totalCount})`; - selectedTab.textContent = `Active Tracks (${selCount})`; + allTab.textContent = `All ${itemLabelCap} (${totalCount})`; + selectedTab.textContent = `Active ${itemLabelCap} (${selCount})`; const showSelected = toggleCheckbox.checked; allTab.classList.toggle("active", !showSelected); selectedTab.classList.toggle("active", showSelected); } updateSelectedText(); // Clicking a tab switches the selection filter and redraws. "Selected" // is always clickable; with nothing selected it just shows an empty list. function setFilterMode(showSelected) { toggleCheckbox.checked = showSelected; table.draw(); updateSelectedText(); } allTab.addEventListener("click", () => setFilterMode(false)); selectedTab.addEventListener("click", () => setFilterMode(true)); // Unified handler for selection changes function onSelectionChanged() { const selCount = table.rows({selected: true}).count(); // Keep the "Selected" view in sync as rows are (de)selected; no need // to redraw while showing all rows. if (toggleCheckbox.checked) { table.draw(); } // Auto-switch Display Mode on 0<->nonzero transitions if (visDropdown) { if (selCount === 0 && prevSelCount > 0) { visDropdown.value = "hide"; } else if (selCount > 0 && prevSelCount === 0) { visDropdown.value = preferredVis; } } updateSelectAllCheckbox(table); updateSelectedText(); prevSelCount = selCount; } table.on("select deselect", onSelectionChanged); // Create active-filters chip bar (hidden when empty) const activeFiltersDiv = document.createElement("div"); activeFiltersDiv.id = "active-filters"; activeFiltersDiv.style.display = "none"; 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 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 () { 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(); }); // 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({ search: "applied" }).select(); } else { table.rows({ search: "applied" }).deselect(); } }); // Wrap the table in a horizontally-scrolling box. When the metadata has // many fields the table is wider than the viewport; this gives it its // own internal X scrollbar instead of letting it spill off the right // edge of the screen (which also dragged the "Show N" / paging controls // off-screen). The toolbar rows stay outside this box, so they remain // visible at the wrapper's width regardless of how wide the table gets. const scrollBox = document.createElement("div"); scrollBox.className = "table-xscroll"; tableEl.parentNode.insertBefore(scrollBox, tableEl); scrollBox.appendChild(tableEl); return table; } // end initTable // Map of colName -> Map of lowercaseId -> spanElement, for dynamic counts const countSpans = new Map(); // Filter state for cross-facet count computation const checkboxFilters = new Map(); // colName -> Set<string> (lowercase ids) 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 id -> count for (const row of metadata) { let passes = true; for (const [col, idSet] of checkboxFilters) { if (col === facetCol) continue; if (!parseCellIds(row[col]).some(id => idSet.has(id))) { passes = false; break; } } if (passes) { for (const [col, text] of textFilters) { if (!row[col]?.toLowerCase().includes(text)) { passes = false; break; } } } if (passes) { for (const id of parseCellIds(row[facetCol])) { counts.set(id, (counts.get(id) ?? 0) + 1); } } } for (const [id, span] of valMap) { span.textContent = `(${counts.get(id) ?? 0})`; } } } function initFilters(table, allData) { const { metadata, colorMap, colNames } = allData; // iterate once over entire data not separately per attribute // Keyed by lowercase id; first-seen label (or id) is the canonical display. const possibleValues = {}; // key -> Map<lowerId, {id, display, count}> for (const entry of metadata) { for (const [key, val] of Object.entries(entry)) { if (!possibleValues[key]) possibleValues[key] = new Map(); const map = possibleValues[key]; for (const tok of parseCsvValues(val)) { const { id, label } = parseValue(tok); const idLower = id.toLowerCase(); const existing = map.get(idLower); if (existing) { existing.count++; } else { map.set(idLower, { id, display: label ?? id, count: 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) => { // skip attributes if they should be excluded from checkbox sets if (excludeCheckboxes.includes(key) || key.startsWith("_")) { return; } // possibleValues[key] is Map<lowerId, {id, display, count}> const sortedPossibleVals = Array.from(possibleValues[key].values()); sortedPossibleVals.sort((a, b) => b.count - a.count); // Use 'maxCheckboxes' most frequent items (if they appear > 1 time) let topToShow = sortedPossibleVals .filter(({id, count}) => id.trim().toUpperCase() !== "NA" && count > 1) .slice(0, maxCheckboxes); // Any "other/Other/OTHER" entry will be put at the end let otherEntry = null; topToShow = topToShow.filter(entry => { if (entry.id.toLowerCase() === "other") { otherEntry = entry; return false; } return true; }); if (otherEntry !== null) topToShow.push(otherEntry); if (topToShow.length <= 1) { // no point if there's only one group excludeCheckboxes.push(key); return; } // --- 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: toTitleStyle(key), className: "facet-heading", }); 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 = []; if (!countSpans.has(key)) countSpans.set(key, new Map()); const colSpans = countSpans.get(key); topToShow.forEach(({id, display, count}) => { const label = document.createElement("label"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.dataset.valueId = id; label.appendChild(checkbox); if (colorMap && key in colorMap) { const colorBox = document.createElement("span"); colorBox.classList.add("color-box"); if (id in colorMap[key]) { colorBox.style.backgroundColor = colorMap[key][id]; } label.appendChild(colorBox); } label.appendChild(document.createTextNode(`${display} `)); const countSpan = document.createElement("span"); countSpan.textContent = `(${count})`; label.appendChild(countSpan); colSpans.set(id.toLowerCase(), countSpan); facetBody.appendChild(label); cboxes.push(checkbox); }); 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 --- // Filtering is handled by the custom search extension below, which // parses each cell's ids and checks them against checkboxFilters. cboxes.forEach(cb => { cb.addEventListener("change", () => { const checkedIds = new Set( cboxes.filter(c => c.checked) .map(c => c.dataset.valueId.toLowerCase()) ); if (checkedIds.size) { checkboxFilters.set(key, checkedIds); } else { checkboxFilters.delete(key); } table.draw(); updateActiveFilters(); }); }); // --- Wire up Clear button --- clearBtn.addEventListener("click", () => { cboxes.forEach(cb => cb.checked = false); checkboxFilters.delete(key); table.draw(); updateActiveFilters(); }); }); // done creating collapsible checkbox filters for each column // Custom search extension: filter rows by parsed cell ids vs checkboxFilters. // Replaces the old per-column regex search so that id-based collapsing works // (e.g. "CD8+T" and "CD8+T|CD8+ T Cells" both match the same facet entry). $.fn.dataTable.ext.search.push(function(_, __, dataIndex) { const rowData = table.row(dataIndex).data(); for (const [col, idSet] of checkboxFilters) { if (!parseCellIds(rowData[col]).some(id => idSet.has(id))) return false; } return true; }); // 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; } // 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, 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, 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 } } // Build the parameters for the cart update const uriForUpdate = new URLSearchParams({ "cartDump.metaDataId": mdid, "noDisplay": 1 }); // Data elements: was and now if (initialState.dataElements.size > 0) { initialState.dataElements.forEach(de => uriForUpdate.append(`${mdid}.de_was`, de)); } else { uriForUpdate.append(`${mdid}.de_was`, ""); } if (currentDataElements.length > 0) { currentDataElements.forEach(de => uriForUpdate.append(`${mdid}.de_now`, de)); } else { uriForUpdate.append(`${mdid}.de_now`, ""); } if (hasDataTypes) { // Data types: was and now 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, 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; const req = (fetchUrl.length > 2048 || embeddedData.udcTimeout) ? fetch("/cgi-bin/hgTrackUi", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: fetchBody, }) : fetch(fetchUrl, { method: "GET", headers: { "Content-Type": "application/x-www-form-urlencoded" }, }); req.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, hgsid, track).then(colorMap => { const rows = tsvText.trim().split("\n"); const colNames = parseTsvRow(rows[0]); if (!primaryKey) throw new Error("trackDb setting 'primaryKey' is missing"); if (!colNames.includes(primaryKey)) throw new Error(`primaryKey '${primaryKey}' not found in metadata columns`); const metadata = rows.slice(1).map(row => { const values = parseTsvRow(row); const obj = {}; colNames.forEach((attrib, i) => { obj[attrib] = values[i] ?? ""; }); return obj; }); // Each primaryKey cell must map to exactly one subtrack. const badPk = metadata.find(row => parseCsvValues(String(row[primaryKey] ?? "")).length > 1); if (badPk) throw new Error( `primaryKey column '${primaryKey}' has multiple values in ` + `'${badPk[primaryKey]}'; only one value is allowed per primaryKey cell`); const rowToIdx = Object.fromEntries( metadata.map((row, i) => [primaryKeyId(row[primaryKey]), i]) ); colorMap = isValidColorMap(colorMap) ? colorMap : null; const freshData = { metadata, rowToIdx, colNames, colorMap }; initAll(freshData); }); }) .catch(err => { hideLoading(); // stop the spinner before showing the error const table = document.getElementById("theMetaDataTable"); if (table) { table.innerHTML = `<tr><td style="padding:20px;color:#a00;">` + `Error loading metadata: ${err.message}</td></tr>`; } }); } // end loadDataAndInit document.addEventListener("keydown", e => { // block accidental submit if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); } }, true); generateHTML(); showLoading(); // show spinner immediately, before the metadata fetch loadDataAndInit(); });