f04876658f4ae0ec7c633895c21f153975a2a506
max
Tue Sep 15 05:48:42 2026 -0700
faceted composite: default sort column, restored drag order, and two smaller things, refs #38353
defaultSortCol was still 1, which the drag-handle column took over when manual row ordering
went in. A composite with no defaultSortField opened sorted by a hidden column, with no sort
arrow on any visible heading.
Fixing that alone exposed the next one: coming back on the Active tab called setFilterMode(),
which renumbers the order field from whatever is on screen, and that threw away the hand-
dragged order just read back from localStorage. It was invisible only because the current
order happened to be the drag order while the default sort column was the drag column. The
restore now keeps the saved order; a click still renumbers, which is what a click means.
Saved state had no eviction, so once the quota filled, the QuotaExceededError was swallowed
and from then on no faceted composite on the origin could save anything again. Each entry
now carries a timestamp and a full store drops the least recently written entry belonging to
another composite and tries again.
Narrowing a facet turned the track container back on even with nothing selected, which brings
back an empty image.
diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index 3ccac1a4448..587ca5a2bea 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -1,1495 +1,1554 @@
// 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 {
- localStorage.setItem(uiStateKey,
- JSON.stringify(Object.assign(loadUiState(), patch)));
+ write();
} catch (e) {
- /* private window, or the quota is full; the page works without it */
+ // 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.
- let defaultSortCol = 1; // column 0 is checkboxes, 1 is first data col
+ // 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
// '