403ab7c9c2b204f487bb2f86260ffdab355e9517
jcasper
Wed Aug 19 05:49:25 2026 -0700
Faceted composites should apply the active sort order to the tracks being
displayed; changing the sort changes the display order. We also preserve that order when
returning to the page. refs #36320
diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index b6796214e5e..f19ff0657bf 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -1,941 +1,1015 @@
// 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:",
}));
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);
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 (case-insensitive, ignoring leading underscores),
- // otherwise fall back to the first data column.
+ // 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 target = embeddedData.defaultSortField.replace(/^_+/, "").toLowerCase();
- const idx = colNames.findIndex(
- c => c.replace(/^_+/, "").toLowerCase() === target);
- if (idx >= 0)
- defaultSortCol = idx + 1; // +1 for the checkbox column
+ 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
+ // '