dadb8aee84f0f7663c3cf0d82f1e44d3c8b7c888 jcasper Mon Jul 6 15:04:34 2026 -0700 Some faceted composite tweaks - better support for color files (match ID instead of full string), better label support in general, reworded Selected to Active, refs #36320 diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js index 4bf78703583..b6796214e5e 100644 --- src/hg/js/facetedComposite.js +++ src/hg/js/facetedComposite.js @@ -48,30 +48,99 @@ 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"; @@ -160,52 +229,49 @@ 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 = String(data).split(",") - .map(s => s.trim()) - .filter(Boolean); + const parts = parseCsvValues(String(data)); + if (!parts.length) return String(data); return parts.map(tok => { - let idForUrl = tok, label = tok, encode = true; - const bar = tok.indexOf("|"); - if (bar >= 0) { - idForUrl = tok.slice(0, bar); - label = tok.slice(bar + 1); - encode = false; - // Strip enclosing quotes from the metadata.tsv - if (label.length >= 2 && - label.startsWith('"') && label.endsWith('"')) { - label = label.slice(1, -1); - } - } - if (/^https?:/i.test(label)) encode = false; - const sub = encode ? encodeURIComponent(idForUrl) : idForUrl; + 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">${label}</a>`; + 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 && @@ -323,31 +389,31 @@ 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 (${totalCount})`; - selectedTab.textContent = `Selected (${selCount})`; + selectedTab.textContent = `Active (${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)); @@ -439,222 +505,224 @@ // 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 unescapedValue -> spanElement, for dynamic counts + // 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> (raw values) + 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 value -> count + const counts = new Map(); // lowercased id -> count for (const row of metadata) { let passes = true; - for (const [col, valueSet] of checkboxFilters) { + for (const [col, idSet] of checkboxFilters) { if (col === facetCol) continue; - if (!valueSet.has(row[col]?.toLowerCase())) { + 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) { - const val = row[facetCol]?.toLowerCase(); - counts.set(val, (counts.get(val) ?? 0) + 1); + for (const id of parseCellIds(row[facetCol])) { + counts.set(id, (counts.get(id) ?? 0) + 1); } } - for (const [val, span] of valMap) { - span.textContent = `(${counts.get(val.toLowerCase()) ?? 0})`; + } + 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 - // Case-insensitive: merge variants, keep first-seen casing as display form - const possibleValues = {}; // key -> Map<lowerVal, [displayVal, count]> + // 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(); - } + if (!possibleValues[key]) possibleValues[key] = new Map(); const map = possibleValues[key]; - const lower = val.toLowerCase(); - const existing = map.get(lower); + for (const tok of parseCsvValues(val)) { + const { id, label } = parseValue(tok); + const idLower = id.toLowerCase(); + const existing = map.get(idLower); if (existing) { - existing[1]++; + existing.count++; } else { - map.set(lower, [val, 1]); + 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, colIdx) => { + colNames.forEach((key) => { // skip attributes if they should be excluded from checkbox sets if (excludeCheckboxes.includes(key) || key.startsWith("_")) { return; } - // possibleValues[key] is Map<lower, [displayVal, count]>; extract [displayVal, count] + // possibleValues[key] is Map<lowerId, {id, display, count}> const sortedPossibleVals = Array.from(possibleValues[key].values()); - sortedPossibleVals.sort((a, b) => b[1] - a[1]); // sort by count descending + sortedPossibleVals.sort((a, b) => b.count - a.count); // Use 'maxCheckboxes' most frequent items (if they appear > 1 time) let topToShow = sortedPossibleVals - .filter(([val, count]) => - val.trim().toUpperCase() !== "NA" && count > 1) + .filter(({id, count}) => + id.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; - } + let otherEntry = null; + topToShow = topToShow.filter(entry => { + if (entry.id.toLowerCase() === "other") { otherEntry = entry; return false; } return true; }); - if (otherValue !== null) { - topToShow.push([otherKey, otherValue]); - } + 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 = []; - 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]) => { + topToShow.forEach(({id, display, count}) => { const label = document.createElement("label"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; - checkbox.value = escapeRegex(val); + checkbox.dataset.valueId = id; 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]; + if (id in colorMap[key]) { + colorBox.style.backgroundColor = colorMap[key][id]; } label.appendChild(colorBox); } - label.appendChild(document.createTextNode(`${val} `)); + label.appendChild(document.createTextNode(`${display} `)); const countSpan = document.createElement("span"); countSpan.textContent = `(${count})`; label.appendChild(countSpan); - colSpans.set(val, countSpan); + colSpans.set(id.toLowerCase(), 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; + // --- 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 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); + 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.column(dtColIdx).search(query, true, false).draw(); + table.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(); + 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"; @@ -812,50 +880,48 @@ 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 = rows[0].split("\t"); + 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 = row.split("\t"); + const values = parseTsvRow(row); const obj = {}; - colNames.forEach((attrib, i) => { obj[attrib] = values[i]; }); + colNames.forEach((attrib, i) => { obj[attrib] = values[i] ?? ""; }); return obj; }); - // Commas in the primaryKey column are ambiguous: a row maps - // to a single subtrack, and trackDb subtrack names can't - // contain commas anyway. Fail loudly so the author notices. + // Each primaryKey cell must map to exactly one subtrack. const badPk = metadata.find(row => - row[primaryKey] != null && String(row[primaryKey]).includes(",")); + parseCsvValues(String(row[primaryKey] ?? "")).length > 1); if (badPk) throw new Error( - `primaryKey column '${primaryKey}' contains a comma in value ` + - `'${badPk[primaryKey]}'; commas are not allowed in primaryKey values`); + `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;">` +