72de5a10dd13d893dd4111ac8acfdc29579760e8
jcasper
Sat Apr 25 10:18:50 2026 -0700
Fixing a conflict in faceted composites between the use of primaryKey values as
data elements (for subtrack names) and the features around linking out (id|label stuff). refs #36320
diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index 0a54703ad1d..3b3c88af412 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -1,809 +1,832 @@
// 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
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 toTitleCase = str =>
str.toLowerCase()
.split(/[_\s-]+/) // Split on underscore, space, or hyphen
.map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
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;
+ };
+
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);
}
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: toTitleCase(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);
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 href = urlTemplate.replace(/\$\$/g, sub);
return `${label}`;
}).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];
// 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.
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 table = $("#theMetaDataTable").DataTable({
data: metadata,
deferRender: true, // seems faster
columns: columns,
columnDefs: [ { targets:0, render: DataTable.render.select() } ],
responsive: true,
layout: {
topStart: 'pageLength',
topEnd: null, // omit global search
bottomStart: 'info',
bottomEnd: 'paging'
},
order: [[defaultSortCol, "asc"]],
pageLength: 25, // show 25 rows per page by default
lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]],
language: {
lengthMenu: `Show _MENU_ ${itemLabel}`,
select: {
rows: {
0: "",
1: `1 ${singularLabel} selected`,
_: `%d ${itemLabel} selected`
}
},
info: `Showing _START_ to _END_ of _TOTAL_ ${itemLabel}`,
infoFiltered: `(filtered from _MAX_ total ${itemLabel})`,
},
select: { style: "multi", selector: "td:not(:has(a))" },
initComplete: function() { // Check appropriate boxes
const api = this.api();
embeddedData.dataElements.forEach(rowName => {
const rowIndex = rowToIdx[rowName];
if (rowIndex !== undefined) {
api.row(rowIndex).select();
}
});
// Capture initial data element state
initialState.dataElements = new Set(embeddedData.dataElements);
},
drawCallback: function() {
updateSelectAllCheckbox(this.api());
},
});
function updateSelectAllCheckbox(api) {
const filteredCount = api.rows({ search: "applied" }).count();
const selectedCount = api.rows({ search: "applied", selected: true }).count();
$("#select-all")
.prop("checked", filteredCount > 0 && selectedCount === filteredCount)
.prop("indeterminate", selectedCount > 0 && selectedCount < filteredCount);
}
// Find the Display Mode dropdown rendered by C code
const visDropdown = document.querySelector(
'select[name="' + embeddedData.track + '"]');
// Track preferred non-hide visibility for auto-restore
let preferredVis = "full";
if (visDropdown && visDropdown.value !== "hide") {
preferredVis = visDropdown.value;
}
// Track previous selection count for detecting 0<->nonzero transitions
let prevSelCount = table.rows({selected: true}).count();
// Update preferredVis when user manually changes the dropdown
if (visDropdown) {
visDropdown.addEventListener("change", function() {
if (this.value !== "hide") {
preferredVis = this.value;
}
});
}
updateSelectAllCheckbox(table); // set initial state after pre-selections
// Create "show only selected" toggle in the toolbar
const lengthDiv = document.querySelector(
"#theMetaDataTable_wrapper .dt-length");
const toggleWrapper = document.createElement("div");
toggleWrapper.id = "selected-filter";
const toggleLabel = document.createElement("label");
toggleLabel.classList.add("toggle-switch");
const toggleCheckbox = document.createElement("input");
toggleCheckbox.type = "checkbox";
toggleCheckbox.dataset.selectFilter = "true";
toggleLabel.appendChild(toggleCheckbox);
toggleLabel.appendChild(Object.assign(
document.createElement("span"), {className: "toggle-slider"}));
toggleWrapper.appendChild(toggleLabel);
const toggleText = Object.assign(
document.createElement("span"), {id: "selected-filter-text"});
toggleWrapper.appendChild(toggleText);
lengthDiv.appendChild(toggleWrapper);
// Disable toggle initially if no rows are selected
toggleCheckbox.disabled = (prevSelCount === 0);
function updateSelectedText() {
const selCount = table.rows({selected: true}).count();
const totalCount = table.rows().count();
toggleText.textContent =
`Show only selected ${itemLabel} (${selCount} of ${totalCount} selected)`;
}
updateSelectedText();
// Unified handler for selection changes
function onSelectionChanged() {
const selCount = table.rows({selected: true}).count();
// Disable toggle when nothing is selected; auto-uncheck if count hits 0
toggleCheckbox.disabled = (selCount === 0);
if (selCount === 0 && toggleCheckbox.checked) {
toggleCheckbox.checked = false;
table.draw();
}
// Auto-switch Display Mode on 0<->nonzero transitions
if (visDropdown) {
if (selCount === 0 && prevSelCount > 0) {
visDropdown.value = "hide";
} else if (selCount > 0 && prevSelCount === 0) {
visDropdown.value = preferredVis;
}
}
updateSelectAllCheckbox(table);
updateSelectedText();
prevSelCount = selCount;
}
table.on("select deselect", onSelectionChanged);
// Create active-filters chip bar (hidden when empty)
const activeFiltersDiv = document.createElement("div");
activeFiltersDiv.id = "active-filters";
activeFiltersDiv.style.display = "none";
const tableEl = document.getElementById("theMetaDataTable");
tableEl.parentNode.insertBefore(activeFiltersDiv, tableEl);
// define inputs for search functionality for each column in the table
const row = document.querySelector("#theMetaDataTable thead").insertRow();
columns.forEach((col) => {
const cell = row.insertCell();
if (col.data === null) {
// left empty; toggle is now in the toolbar
} else if (col.data && col.data.startsWith("__")) {
// no search box for double-underscore columns
} else {
const input = document.createElement("input");
input.type = "text";
input.placeholder = "Search...";
input.style.width = "100%";
cell.appendChild(input);
}
});
// behaviors for the column-based search functionality
$("#theMetaDataTable thead input[type='text']")
.on("keyup change", function () {
const dtColIdx = $(this).parent().index();
const colName = colNames[dtColIdx - 1]; // offset for checkbox col
if (this.value) {
textFilters.set(colName, this.value.toLowerCase());
} else {
textFilters.delete(colName);
}
table.column(dtColIdx).search(this.value).draw();
});
$.fn.dataTable.ext.search.push(function (_, data, dataIndex) {
const filterInput =
document.querySelector("input[data-select-filter]");
if (!filterInput?.checked) { // If checkbox not checked, show all rows
return true;
}
// Otherwise, only show selected rows
const row = table.row(dataIndex);
return row.select && row.selected();
});
$("#selected-filter input[data-select-filter]")
.on("change", function () { table.draw(); });
// implement the 'select all' at the top of the checkbox column
$("#select-all").closest("label").attr(
"title", `Select all filtered ${itemLabel}`);
$("#theMetaDataTable thead").on("click", "#select-all", function () {
const rowIsChecked = this.checked;
if (rowIsChecked) {
table.rows({ search: "applied" }).select();
} else {
table.rows({ search: "applied" }).deselect();
}
});
return table;
} // end initTable
// Map of colName -> Map of unescapedValue -> spanElement, for dynamic counts
const countSpans = new Map();
// Filter state for cross-facet count computation
const checkboxFilters = new Map(); // colName -> Set (raw values)
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
for (const row of metadata) {
let passes = true;
for (const [col, valueSet] of checkboxFilters) {
if (col === facetCol) continue;
if (!valueSet.has(row[col]?.toLowerCase())) {
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 [val, span] of valMap) {
span.textContent = `(${counts.get(val.toLowerCase()) ?? 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
for (const entry of metadata) {
for (const [key, val] of Object.entries(entry)) {
if (!possibleValues[key]) {
possibleValues[key] = new Map();
}
const map = possibleValues[key];
const lower = val.toLowerCase();
const existing = map.get(lower);
if (existing) {
existing[1]++;
} else {
map.set(lower, [val, 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) => {
// skip attributes if they should be excluded from checkbox sets
if (excludeCheckboxes.includes(key) || key.startsWith("_")) {
return;
}
// possibleValues[key] is Map; extract [displayVal, count]
const sortedPossibleVals = Array.from(possibleValues[key].values());
sortedPossibleVals.sort((a, b) => b[1] - a[1]); // sort by count descending
// Use 'maxCheckboxes' most frequent items (if they appear > 1 time)
let topToShow = sortedPossibleVals
.filter(([val, count]) =>
val.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;
}
return true;
});
if (otherValue !== null) {
topToShow.push([otherKey, otherValue]);
}
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: toTitleCase(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]) => {
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = escapeRegex(val);
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];
}
label.appendChild(colorBox);
}
label.appendChild(document.createTextNode(`${val} `));
const countSpan = document.createElement("span");
countSpan.textContent = `(${count})`;
label.appendChild(countSpan);
colSpans.set(val, 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;
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);
} else {
checkboxFilters.delete(key);
}
table.column(dtColIdx).search(query, true, false).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();
updateActiveFilters();
});
}); // done creating collapsible checkbox filters for each column
// 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";
return;
}
// Group by facet name
const groups = new Map();
checked.forEach(cb => {
const facetGroup = cb.closest(".facet-group");
if (!facetGroup) return;
const heading = facetGroup.querySelector(".facet-heading");
if (!heading) return;
const facetName = heading.textContent.trim();
// Get the display text from the label (strip the count suffix)
const label = cb.parentElement;
const labelText = label.textContent.trim();
if (!groups.has(facetName)) groups.set(facetName, []);
groups.get(facetName).push({ labelText, checkbox: cb });
});
groups.forEach((chips, facetName) => {
const groupLabel = document.createElement("span");
groupLabel.className = "filter-chip-group-label";
groupLabel.textContent = facetName + ":";
container.appendChild(groupLabel);
chips.forEach(({ labelText, checkbox }) => {
const chip = document.createElement("span");
chip.className = "filter-chip";
chip.appendChild(document.createTextNode(labelText + " "));
const removeBtn = document.createElement("button");
removeBtn.className = "remove-chip";
removeBtn.type = "button";
removeBtn.textContent = "\u00d7";
removeBtn.addEventListener("click", () => {
checkbox.checked = false;
checkbox.dispatchEvent(new Event("change"));
});
chip.appendChild(removeBtn);
container.appendChild(chip);
});
});
container.style.display = "flex";
}
function initSubmit(table) { // logic for the submit event
const { mdid, primaryKey } = embeddedData; // mdid: metadata identifier
const hasDataTypes = embeddedData.dataTypes &&
Object.keys(embeddedData.dataTypes).length > 0;
document.getElementById("Submit").addEventListener("click", (submitBtnEvent) => {
submitBtnEvent.preventDefault(); // hold the submit button event
const currentDataTypes = [];
if (hasDataTypes) {
// Get current data type selections
document.querySelectorAll("input.cbgroup").forEach(cb => {
if (cb.checked) {
currentDataTypes.push(cb.value);
}
});
// Require at least one data type when the selector exists
if (currentDataTypes.length === 0) {
alert("Please select at least one data type.");
return; // abort submission
}
}
// Get current data element selections
const currentDataElements = table.rows({selected: true}).data().toArray()
- .map(obj => obj[primaryKey]);
+ .map(obj => primaryKeyId(obj[primaryKey]));
// Enforce an upper bound on the number of tracks on at the same time.
// This is imperfect when data types are present - some combinations might
// have been manually hidden by the user. But it should be a good ballpark.
const trackLimit = 1000;
if (hasDataTypes) {
if (currentDataTypes.length * currentDataElements.length > trackLimit) {
alert("You have turned on too many subtracks (over 1000) - please uncheck some.");
return; // abort submission
}
} else {
if (currentDataElements.length > trackLimit) {
alert("You have turned on too many subtracks (over 1000) - please uncheck some.");
return; // abort submission
}
}
// Build the parameters for the cart update
const uriForUpdate = new URLSearchParams({
"cartDump.metaDataId": mdid,
"noDisplay": 1
});
// Data elements: was and now
if (initialState.dataElements.size > 0) {
initialState.dataElements.forEach(de =>
uriForUpdate.append(`${mdid}.de_was`, de));
} else {
uriForUpdate.append(`${mdid}.de_was`, "");
}
if (currentDataElements.length > 0) {
currentDataElements.forEach(de =>
uriForUpdate.append(`${mdid}.de_now`, de));
} else {
uriForUpdate.append(`${mdid}.de_now`, "");
}
if (hasDataTypes) {
// Data types: was and now
if (initialState.dataTypes.size > 0) {
initialState.dataTypes.forEach(dt => {
uriForUpdate.append(`${mdid}.dt_was`, dt);});
} else {
uriForUpdate.append(`${mdid}.dt_was`, "");
}
if (currentDataTypes.length > 0) {
currentDataTypes.forEach(dt => {
uriForUpdate.append(`${mdid}.dt_now`, dt);});
} else {
uriForUpdate.append(`${mdid}.dt_now`, "");
}
}
// No ${mdid}.dt* variables indicates that the composite doesn't use data types
updateVisibilities(uriForUpdate, submitBtnEvent);
});
} // end initSubmit
function initAll(dataForTable) {
initDataTypeSelector();
const table = initTable(dataForTable);
initFilters(table, dataForTable);
initSubmit(table);
}
function loadDataAndInit() { // load data and call init functions
const { mdid, primaryKey, metadataUrl, colorSettingsUrl, track } = embeddedData;
const paramsFromUrl = new URLSearchParams(window.location.search);
const hgsid = paramsFromUrl.get("hgsid");
let fetchBody = `fileUrl=${metadataUrl}&track=${track}`;
if (hgsid !== null) {
fetchBody = fetchBody + `&hgsid=${hgsid}`;
}
// fetch file dynamically
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" },
});
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");
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 obj = {};
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.
+ const badPk = metadata.find(row =>
+ row[primaryKey] != null && String(row[primaryKey]).includes(","));
+ if (badPk)
+ throw new Error(
+ `primaryKey column '${primaryKey}' contains a comma in value ` +
+ `'${badPk[primaryKey]}'; commas are not allowed in primaryKey values`);
const rowToIdx = Object.fromEntries(
- metadata.map((row, i) => [row[primaryKey], i])
+ metadata.map((row, i) => [primaryKeyId(row[primaryKey]), i])
);
colorMap = isValidColorMap(colorMap) ? colorMap : null;
const freshData = { metadata, rowToIdx, colNames, colorMap };
initAll(freshData);
});
})
.catch(err => {
const table = document.getElementById("theMetaDataTable");
if (table) {
table.innerHTML =
`
` +
`Error loading metadata: ${err.message}
`;
}
});
} // end loadDataAndInit
document.addEventListener("keydown", e => { // block accidental submit
if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); }
}, true);
generateHTML();
loadDataAndInit();
});