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 `${label}`;
+ return `${displayLabel}`;
}).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: `
`,
// 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 (raw values)
+ const checkboxFilters = new Map(); // colName -> Set (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
+ // Keyed by lowercase id; first-seen label (or id) is the canonical display.
+ const possibleValues = {}; // key -> Map
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; extract [displayVal, count]
+ // possibleValues[key] is Map
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 =
`| ` +
|