65bf003397e55927776facd7654de161ea8c1e20 lrnassar Fri Jan 30 15:59:33 2026 -0800 Downloading our major CDNs and changing their references to all be local, there are some more corner cases that were low priority as discussed in the ticket. This work is done to improve performance, expecially for overseas users. Refs #33998 diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js index 2a2f31e613b..8c2298f5acc 100644 --- src/hg/js/facetedComposite.js +++ src/hg/js/facetedComposite.js @@ -1,442 +1,442 @@ // 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 // ADS: need "matching" versions for the plugins - const DATATABLES_URL = "https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"; - const DATATABLES_SELECT_URL = "https://cdn.datatables.net/select/1.7.0/js/dataTables.select.min.js"; + const DATATABLES_URL = "../js/dataTables-1.13.6.min.js"; + const DATATABLES_SELECT_URL = "../js/dataTables.select-1.7.0.min.js"; const CSS_URLS = [ - "https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css", // dataTables CSS - "https://cdn.datatables.net/select/1.7.0/css/select.dataTables.min.css", // dataTables Select CSS + "../style/dataTables-1.13.6.min.css", // dataTables CSS + "../style/dataTables.select-1.7.0.min.css", // dataTables Select CSS "../style/facetedComposite.css", // Local metadata table CSS ]; 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")); const loadOptional = url => // load if possible otherwise carry on url ? fetch(url).then(r => r.ok ? r.json() : null).catch(() => null) : Promise.resolve(null); const loadIfMissing = (condition, url, callback) => // for missing plugins condition ? document.head.appendChild(Object.assign( document.createElement("script"), { src: url, onload: callback })) : callback(); 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, "\\$&"); 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) : ""; })(); function generateHTML() { const container = document.createElement("div"); container.id = "myTag"; container.innerHTML = ` <div id="dataTypeSelector"></div> <div id="container"> <div id="filters"></div> <table id="theMetaDataTable"> <thead></thead> <tfoot></tfoot> </table> </div> `; // 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"); fetch("/cgi-bin/cartDump", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `hgsid=${hgsid}&db=${db}&${uriForUpdate}`, }).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: "<b>Data type</b>", })); Object.keys(embeddedData.dataTypes).forEach(name => { const label = document.createElement("label"); label.innerHTML = ` <input type="checkbox" class="cbgroup" value="${name}">${name}`; selector.appendChild(label); }); const selectedDataTypes = new Set( // get dataTypes selected initially Object.entries(embeddedData.dataTypes).filter(([_, val]) => val === 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); }); } function initTable(allData) { const { metadata, rowToIdx, colNames } = allData; const ordinaryColumns = colNames.map(key => ({ // all but checkboxes data: key, title: toTitleCase(key), })); const checkboxColumn = { data: null, orderable: false, className: "select-checkbox", defaultContent: "", title: ` <label title="Select all visible rows"> <input type="checkbox" id="select-all"/></label>`, // no render function needed }; const columns = [checkboxColumn, ...ordinaryColumns]; const table = $("#theMetaDataTable").DataTable({ data: metadata, deferRender: true, // seems faster columns: columns, responsive: true, // autoWidth: true, // might help columns shrink to fit content order: [[1, "asc"]], // sort by the first data column, not checkbox pageLength: 50, // show 50 rows per page by default lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]], select: { style: "multi", selector: "td:first-child" }, initComplete: function() { // Check appropriate boxes const api = this.api(); embeddedData.dataElements.forEach(rowName => { const rowIndex = rowToIdx[rowName]; if (rowIndex !== undefined) { api.row(rowIndex).select(); } }); }, drawCallback: function() { // Reset header "select all" checkbox $("#select-all") .prop("checked", false) .prop("indeterminate", false); }, }); // 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.className === "select-checkbox") { // show selected items const label = document.createElement("label"); label.title = "Show only selected rows"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.dataset.selectFilter = "true"; label.appendChild(checkbox); cell.appendChild(label); } 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 () { table.column($(this).parent().index()).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(); }); $("#theMetaDataTable thead input[data-select-filter]") .on("change", function () { table.draw(); }); // implement the 'select all' at the top of the checkbox column $("#theMetaDataTable thead").on("click", "#select-all", function () { const rowIsChecked = this.checked; if (rowIsChecked) { table.rows({ page: "current" }).select(); } else { table.rows({ page: "current" }).deselect(); } }); return table; } // end initTable function initFilters(table, allData) { const { metadata, colorMap, colNames } = allData; // iterate once over entire data not separately per attribute const possibleValues = {}; for (const entry of metadata) { for (const [key, val] of Object.entries(entry)) { if (possibleValues[key] === null || possibleValues[key] === undefined) { possibleValues[key] = new Map(); } const map = possibleValues[key]; map.set(val, (map.get(val) ?? 0) + 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) => { // skip attributes if they should be excluded from checkbox sets if (excludeCheckboxes.includes(key)) { return; } const sortedPossibleVals = Array.from(possibleValues[key].entries()); sortedPossibleVals.sort((a, b) => // neg: less-than a[1] !== b[1] ? b[1] - a[1] : a[0].localeCompare(b[0])); // Use 'maxCheckboxes' most frequent items (if they appear > 1 time) let topToShow = sortedPossibleVals .filter(([val, count]) => val.trim().toUpperCase() !== "NA") // why only > 1? //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; } // Add headings and filter checkboxes (only top maxCheckboxes) const cbSetsDiv = document.createElement("div"); cbSetsDiv.appendChild(Object.assign(document.createElement("strong"), { textContent: toTitleCase(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} (${count})`)); cbSetsDiv.appendChild(label); }); filtersDiv.appendChild(cbSetsDiv); }); // done creating checkbox filters for each column const checkboxAttributeIndexes = // checkbox sets => cols in data table colNames.reduce((acc, key, colIdx) => { if (!excludeCheckboxes.includes(key)) { acc.push(colIdx); } return acc; }, []); // Now do logic to implement checkboxes-based rows display checkboxAttributeIndexes.forEach((colIdx, idx) => { const attrDiv = filtersDiv.children[idx]; const cboxes = [ // need this to be 'array' to use 'filter' ...attrDiv.querySelectorAll("input[type=checkbox]") ]; cboxes.forEach(cb => { // Add set of checkboxes for this attribute cb.addEventListener("change", () => { const chk = cboxes.filter(c => c.checked).map(c => c.value); const query = chk.length ? "^(" + chk.join("|") + ")$" : ""; table.column(colIdx + 1).search(query, true, false).draw(); }); }); // Make a "clear" button const clearBtn = document.createElement("button"); clearBtn.textContent = "Clear"; clearBtn.type = "button"; // prevent form submit if inside a form clearBtn.addEventListener("click", () => { cboxes.forEach(cb => cb.checked = false); // Uncheck all // Recalculate the (now cleared) search term and update table table.column(colIdx + 1).search("", true, false).draw(); }); // Prepend the "clear" button attrDiv.insertBefore(clearBtn, attrDiv.children[1] || null); }); return table; // to chain calls } // end initFilters 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 selectedRows = table.rows({selected: true}).data().toArray(); const uriForUpdate = new URLSearchParams({ "cartDump.metaDataId": mdid, "noDisplay": 1 }); selectedRows.forEach(obj => // 'de' for data element uriForUpdate.append(`${mdid}.de`, obj[primaryKey])); if (hasDataTypes) { // Collect checked data types const selectedDataTypes = []; document.querySelectorAll("input.cbgroup").forEach(cb => { if (cb.checked) { selectedDataTypes.push(cb.value); } }); // Require at least one data type when the selector exists if (selectedDataTypes.length === 0) { alert("Please select at least one data type."); return; // abort submission } selectedDataTypes.forEach(dat => // 'dt' for data type uriForUpdate.append(`${mdid}.dt`, dat)); } else { // No data types configured for this track: send empty-string sentinel uriForUpdate.append(`${mdid}.dt`, ""); } 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 } = embeddedData; const CACHE_KEY = mdid; const CACHE_TIMESTAMP = `${CACHE_KEY}_time_stamp`; const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours const now = Date.now(); const cachedTime = parseInt(localStorage.getItem(CACHE_TIMESTAMP), 10); let cachedData = null; let useCache = false; if (cachedTime && (now - cachedTime < CACHE_EXPIRY_MS)) { const cachedStr = localStorage.getItem(CACHE_KEY); cachedData = cachedStr ? JSON.parse(cachedStr) : null; useCache = !!cachedData; } if (useCache) { initAll(cachedData); return; } fetch(metadataUrl) .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).then(colorMap => { const rows = tsvText.trim().split("\n"); const colNames = rows[0].split("\t"); const metadata = rows.slice(1).map(row => { const values = row.split("\t"); const obj = {}; colNames.forEach((attrib, i) => { obj[attrib] = values[i]; }); return obj; }); if (!metadata.length || !colNames.length) { localStorage.removeItem(CACHE_KEY); localStorage.removeItem(CACHE_TIMESTAMP); return; } const rowToIdx = Object.fromEntries( metadata.map((row, i) => [row[primaryKey], i]) ); colorMap = isValidColorMap(colorMap) ? colorMap : null; const freshData = { metadata, rowToIdx, colNames, colorMap }; // cache the data localStorage.setItem(CACHE_KEY, JSON.stringify(freshData)); localStorage.setItem(CACHE_TIMESTAMP, now.toString()); initAll(freshData); }); }); } // end loadDataAndInit CSS_URLS.map(href => // load all the CSS document.head.appendChild(Object.assign( document.createElement("link"), { rel: "stylesheet", href }))); document.addEventListener("keydown", e => { // block accidental submit if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); } }, true); // ADS: only load plugins if they are not already loaded loadIfMissing(!$.fn.DataTable, DATATABLES_URL, () => { loadIfMissing(!$.fn.dataTable.select, DATATABLES_SELECT_URL, () => { generateHTML(); loadDataAndInit(); }); }); });