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.
" +
"Click a column heading to sort by that column; click it again to reverse " +
"the direction.
" +
"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 =
`
Loading metadata…
`;
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 = `
`;
// 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: "Subtrack types enabled:",
}));
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 = `
${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 `${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 &&
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
// '