b5af24c744cd0f5571634a863c34f208bbd0a199
max
Wed Sep 23 11:15:40 2026 -0700
hgTrackUi: better error when a faceted composite's metadata file is refused, refs #29344
The 400 error now says which URL was refused and for which track, and
that a hub's metaDataUrl or colorSettingsUrl has to point inside the
hub's directory. facetedComposite.js now shows that text instead of
just "HTTP Status: 400".
Also in facetedComposite.js: facet values with a count of zero are
hidden, unless they are checked, and there is a new "Clear all filters"
button that unchecks all facets and empties the search boxes.
diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index 587ca5a2bea..2ff6b993c3d 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -1,1554 +1,1611 @@
// 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.
" +
"Rows can also be dragged by the handle in the Reorder column, which " +
"appears on the \"shown in the browser\" tab.";
// Hover help for the two selection tabs.
const SHOWN_TAB_HELP =
"The first option shows all samples. Check a sample row to make this " +
"sample visible, i.e. show all its tracks in the Genome Browser. " +
"The second button lists only the samples with visible tracks. Drag to " +
"reorder these tracks or uncheck the sample to hide all its tracks.";
// Hover help for the grouping tabs.
const GROUP_BY_HELP =
"How the tracks are arranged in the Genome Browser image when a sample " +
"has more than one kind of data.
" +
"Group by sample keeps one sample's tracks together, which suits " +
"comparing different kinds of data in the same sample.
" +
"Group by data type puts the same kind of data for every sample " +
"together, which suits comparing one measurement across samples.";
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 === null || str === undefined || str === "") return [];
// Callers mostly pass metadata strings, but a synthetic numeric field
// reaches here too, and a number has no .slice().
if (typeof str !== "string") str = String(str);
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()
};
// Set by initTable(), which owns the Display Mode dropdown. Called from the
// data type and facet handlers, which live in other functions. A no-op
// until the table has loaded, which is before the user can click anything.
let showTracks = () => {};
// Set by initTable(), which owns the two selection tabs. Called from the
// facet handlers in initFilters() to drop back to the full list.
let showAllRows = () => {};
// Set by initTable() once the "Group by" tabs exist, read by initSubmit().
// Returns null for a composite without data types, where there is nothing
// to group and no control is drawn.
let getGroupBy = () => null;
// How this picker was last left: which facet boxes were ticked, what was
// typed in each column's search box, which tab was showing, how many rows
// per page, and a hand-dragged row order if there is one. None of it
// changes what the Genome Browser draws, so it stays out of the cart:
// putting it there would grow every session, and a manual order over a
// table the size of Methbase's 6500 rows would be a large value to carry
// around for a display preference. The sort column is the exception and
// does live in the cart, as facetSortOrder, because it also sets the track
// order in the image.
// The key carries the assembly as well as the metadata id: localStorage is
// per-origin, so two assemblies whose hubs happen to use the same track
// name would otherwise share one saved state, and a row order dragged for
// one would come back on the other over a different set of samples.
const uiStateKey = `facetedComposite.${embeddedData.db || ""}.${embeddedData.mdid}`;
function loadUiState() {
// A private window throws on access rather than returning null, and a
// half-written value from an older build should not break the page.
try {
const raw = localStorage.getItem(uiStateKey);
const state = raw ? JSON.parse(raw) : null;
return (state && typeof state === "object") ? state : {};
} catch (e) {
return {};
}
}
// A page length the user picked wins. Otherwise paginating a table that
// would nearly fit anyway just hides rows behind a menu, so show everything
// up to the first menu step past 25. -1 is what DataTables reads as "all".
function savedPageLength(saved, rowCount) {
if (typeof saved === "number" && saved !== 0)
return saved;
return rowCount < 50 ? -1 : 25;
}
// Drop the least recently written saved state belonging to some *other*
// faceted composite, to make room. Returns false when there is nothing
// left to drop, which is the caller's signal to give up.
function evictOtherUiState() {
try {
let oldestKey = null, oldestTime = Infinity;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith("facetedComposite.") || key === uiStateKey)
continue;
let saved = 0;
try {
// An entry we cannot read is the first one to go.
saved = (JSON.parse(localStorage.getItem(key)) || {}).saved || 0;
} catch (e) {
saved = 0;
}
if (saved < oldestTime) {
oldestTime = saved;
oldestKey = key;
}
}
if (oldestKey === null)
return false;
localStorage.removeItem(oldestKey);
return true;
} catch (e) {
return false;
}
}
function saveUiState(patch) {
// The timestamp is what makes eviction above "least recently written"
// rather than arbitrary.
const write = () => localStorage.setItem(
uiStateKey,
JSON.stringify(Object.assign(loadUiState(), patch, {saved: Date.now()})));
try {
write();
} catch (e) {
// Out of quota. A hand-dragged order is one id per row, so a few
// large composites can fill the store between them; without the
// retry below the first one to hit the limit would leave every
// faceted composite on this origin silently unable to save again.
while (evictOtherUiState()) {
try {
write();
return;
} catch (e2) {
/* still no room; drop another and try again */
}
}
/* private window, or nothing left to evict; the page works without it */
}
}
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 or drag individual rows " +
"to change the order"));
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: "Data types shown in the browser:",
}));
selector.appendChild(createInfoIcon(
"Each sample has several data type tracks in the Genome Browser. " +
"Check the boxes of the types of tracks you wish to show when a " +
"sample row is selected below."));
// Built as nodes rather than from a template string. Both the name and
// the title come from the trackDb 'dataTypes' setting, which on a hub is
// whatever the hub author wrote, so they go in as a property value and a
// text node instead of being interpolated into HTML.
// The leading space is what the old template literal's newline and
// indentation collapsed to, and is what separates one checkbox from the
// one before it.
Object.keys(embeddedData.dataTypes).forEach(name => {
const label = document.createElement("label");
const dataType = embeddedData.dataTypes[name];
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "cbgroup";
cb.value = name;
label.appendChild(document.createTextNode(" "));
label.appendChild(cb);
label.appendChild(document.createTextNode(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); });
// Turning a data type on is a request to see it, so take the container
// out of hide the same way selecting a sample does. These boxes were
// otherwise only read at submit time.
document.querySelectorAll("input.cbgroup").forEach(cb => {
cb.addEventListener("change", () => {
if (cb.checked) showTracks();
});
});
// Capture initial data type state
initialState.dataTypes = new Set(selectedDataTypes);
}
function initTable(allData) {
const { metadata, rowToIdx, colNames } = allData;
const colDescriptions = allData.colDescriptions || {};
const primaryKey = embeddedData.primaryKey;
// 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);
// Drag handle for manual row ordering, and the field RowReorder swaps
// when a row is dropped. It sits second, right after the checkboxes,
// where drag handles are normally looked for and where it cannot scroll
// off the right edge of a narrow window. Everything that maps a
// DataTables column index onto colNames therefore skips two leading
// columns rather than one; DATA_COL_OFFSET below is that count, and the
// sortSpec the submit builds excludes this column by name so a manual
// order is never written to the cart as a nonexistent sort field.
const savedState = loadUiState();
const ORDER_FIELD = "__rowOrder";
metadata.forEach((row, i) => { row[ORDER_FIELD] = i; });
// A hand-dragged order from a previous visit, as primary key values.
// Rows the metadata no longer has are ignored, and rows the saved order
// does not mention keep their file order after the ones it does, so an
// edited metadata file degrades instead of throwing.
if (Array.isArray(savedState.rowOrder) && savedState.rowOrder.length) {
const rank = new Map(savedState.rowOrder.map((id, i) => [String(id), i]));
const big = savedState.rowOrder.length;
metadata.forEach((row, i) => {
const seen = rank.get(String(primaryKeyId(row[primaryKey])));
row[ORDER_FIELD] = (seen === undefined) ? big + i : seen;
});
}
// Six dots in two columns, the conventional drag-handle shape. Drawn
// here rather than taken from Font Awesome: the deployed version is
// 4.5.0, which predates fa-grip-vertical, and this page does not load
// Font Awesome at all. Inline SVG follows createInfoIcon() in utils.js.
const GRIP_SVG =
"";
const reorderColumn = {
data: ORDER_FIELD,
className: "dt-reorder",
orderable: true,
// One word, so the column stays narrow; "Drag to reorder" would sit
// on one line and take about twice the width.
title: "Reorder",
visible: false, // only shown on the Active tab
render: () => GRIP_SVG,
};
const columns = [checkboxColumn, reorderColumn, ...ordinaryColumns];
const reorderColIdx = 1;
// How many non-metadata columns precede the data columns: the select
// checkboxes and the drag handle.
const DATA_COL_OFFSET = 2;
// 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 + DATA_COL_OFFSET : -1;
};
// Determine which column to sort by: use defaultSortField if it matches
// a metadata column, otherwise fall back to the first data column.
// column 0 is the checkboxes and column 1 the drag handle, so the first
// data column is at DATA_COL_OFFSET
let defaultSortCol = DATA_COL_OFFSET;
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
// '