1// Application logic for BoneHub public datasets and subjects tables 2 3const NUMERIC_COLUMN_TOKENS = ['id', 'age', 'weight', 'height', 'bmi']; 4 5document.addEventListener('DOMContentLoaded', function() { 6 initializeTableController({ 7 data: typeof datasetsData !== 'undefined' ? datasetsData : [], 8 itemLabel: 'dataset', 9 itemLabelPlural: 'datasets', 10 emptyMessage: 'No datasets found matching your filters.', 11 noDataMessage: 'No dataset metadata available.', 12 exportFilePrefix: 'bonehub_datasets', 13 searchInputId: 'datasetSearchInput', 14 clearButtonId: 'clearDatasetFilters', 15 toggleColumnsButtonId: 'toggleDatasetColumns', 16 exportButtonId: 'exportDatasetsCSV', 17 countId: 'datasetCount', 18 columnTogglesId: 'datasetColumnToggles', 19 columnCheckboxesId: 'datasetColumnCheckboxes', 20 tableHeaderId: 'datasetsTableHeader', 21 filterRowId: 'datasetsFilterRow', 22 tableBodyId: 'datasetsTableBody' 23 }); 24 25 initializeTableController({ 26 data: typeof subjectsData !== 'undefined' ? subjectsData : [], 27 itemLabel: 'subject', 28 itemLabelPlural: 'subjects', 29 emptyMessage: 'No subjects found matching your filters.', 30 noDataMessage: 'Dataset not loaded. Please run the update script.', 31 exportFilePrefix: 'bonehub_subjects', 32 searchInputId: 'searchInput', 33 clearButtonId: 'clearFilters', 34 toggleColumnsButtonId: 'toggleColumns', 35 exportButtonId: 'exportCSV', 36 countId: 'subjectCount', 37 columnTogglesId: 'columnToggles', 38 columnCheckboxesId: 'columnCheckboxes', 39 tableHeaderId: 'tableHeader', 40 filterRowId: 'filterRow', 41 tableBodyId: 'tableBody' 42 }); 43}); 44 45function initializeTableController(config) { 46 const state = { 47 ...config, 48 allData: Array.isArray(config.data) ? config.data : [], 49 filteredData: Array.isArray(config.data) ? [...config.data] : [], 50 columnFilters: {}, 51 visibleColumns: new Set(), 52 allColumns: [], 53 sortColumn: null, 54 sortDirection: 'asc', 55 isPaginated: config.itemLabel === 'subject', 56 pageSize: config.itemLabel === 'subject' ? 200 : 0, 57 currentPage: 1, 58 filterDebounceMs: config.itemLabel === 'subject' ? 120 : 0, 59 filterTimer: null, 60 cellValueCache: new WeakMap(), 61 rowSearchCache: new WeakMap(), 62 elements: getTableElements(config) 63 }; 64 65 if (!state.elements.headerRow || !state.elements.filterRow || !state.elements.tbody) { 66 return null; 67 } 68 69 state.allColumns = state.allData.length > 0 ? Object.keys(state.allData[0]) : []; 70 state.visibleColumns = new Set(state.allColumns); 71 72 buildTableStructure(state); 73 initializeColumnToggles(state); 74 initializePaginationControls(state); 75 renderTable(state); 76 setupEventListeners(state); 77 updateStickyColumnOffsets(state); 78 79 return state; 80} 81 82function getTableElements(config) { 83 const tbody = document.getElementById(config.tableBodyId); 84 return { 85 searchInput: document.getElementById(config.searchInputId), 86 clearButton: document.getElementById(config.clearButtonId), 87 toggleColumnsButton: document.getElementById(config.toggleColumnsButtonId), 88 exportButton: document.getElementById(config.exportButtonId), 89 countElement: document.getElementById(config.countId), 90 columnToggles: document.getElementById(config.columnTogglesId), 91 columnCheckboxes: document.getElementById(config.columnCheckboxesId), 92 headerRow: document.getElementById(config.tableHeaderId), 93 filterRow: document.getElementById(config.filterRowId), 94 tbody: tbody, 95 tableContainer: tbody ? tbody.closest('.table-container') : null, 96 paginationContainer: null 97 }; 98} 99 100function initializePaginationControls(state) { 101 if (!state.isPaginated || !state.elements.tableContainer) { 102 return; 103 } 104 105 const paginationContainer = document.createElement('div'); 106 paginationContainer.className = 'table-pagination'; 107 paginationContainer.setAttribute('aria-label', `${state.itemLabel} pagination`); 108 state.elements.tableContainer.insertAdjacentElement('afterend', paginationContainer); 109 state.elements.paginationContainer = paginationContainer; 110} 111 112function buildTableStructure(state) { 113 const { headerRow, filterRow } = state.elements; 114 115 headerRow.innerHTML = ''; 116 filterRow.innerHTML = ''; 117 118 if (state.allColumns.length === 0) { 119 return; 120 } 121 122 state.allColumns.forEach((column, index) => { 123 const th = document.createElement('th'); 124 th.textContent = column; 125 th.dataset.column = column; 126 th.dataset.columnIndex = index; 127 th.classList.add('sortable'); 128 th.addEventListener('click', function() { 129 handleSort(state, column); 130 }); 131 headerRow.appendChild(th); 132 133 const td = document.createElement('td'); 134 td.dataset.column = column; 135 td.dataset.columnIndex = index; 136 137 const input = document.createElement('input'); 138 input.type = 'text'; 139 input.className = 'filter-input'; 140 input.dataset.column = column; 141 142 if (isNumericColumn(column)) { 143 input.placeholder = 'e.g., >100 or <50'; 144 input.title = 'Supports: >, <, >=, <=, = followed by a number'; 145 } else { 146 input.placeholder = `Filter ${column}...`; 147 } 148 149 input.addEventListener('input', function(event) { 150 handleColumnFilter(state, column, event.target.value); 151 }); 152 153 td.appendChild(input); 154 filterRow.appendChild(td); 155 }); 156 157 applyColumnVisibility(state); 158 updateSortIndicators(state); 159} 160 161function initializeColumnToggles(state) { 162 const container = state.elements.columnCheckboxes; 163 if (!container) { 164 return; 165 } 166 167 container.innerHTML = ''; 168 169 state.allColumns.forEach(column => { 170 const div = document.createElement('div'); 171 div.className = 'column-checkbox'; 172 173 const checkbox = document.createElement('input'); 174 checkbox.type = 'checkbox'; 175 checkbox.id = `${state.itemLabel}-col-${slugify(column)}`; 176 checkbox.checked = state.visibleColumns.has(column); 177 checkbox.dataset.column = column; 178 179 const label = document.createElement('label'); 180 label.htmlFor = checkbox.id; 181 label.textContent = column; 182 183 checkbox.addEventListener('change', function() { 184 if (this.checked) { 185 state.visibleColumns.add(column); 186 } else { 187 state.visibleColumns.delete(column); 188 } 189 applyColumnVisibility(state); 190 }); 191 192 div.appendChild(checkbox); 193 div.appendChild(label); 194 container.appendChild(div); 195 }); 196} 197 198function setupEventListeners(state) { 199 const { 200 searchInput, 201 clearButton, 202 toggleColumnsButton, 203 exportButton, 204 columnToggles, 205 filterRow 206 } = state.elements; 207 208 if (searchInput) { 209 searchInput.addEventListener('input', function() { 210 scheduleApplyFilters(state, true); 211 }); 212 } 213 214 if (clearButton) { 215 clearButton.addEventListener('click', function() { 216 if (searchInput) { 217 searchInput.value = ''; 218 } 219 220 Array.from(filterRow.querySelectorAll('.filter-input')).forEac
220h(input => { 221 input.value = ''; 222 }); 223 224 state.columnFilters = {}; 225 applyFilters(state, true); 226 }); 227 } 228 229 if (toggleColumnsButton && columnToggles) { 230 toggleColumnsButton.addEventListener('click', function() { 231 columnToggles.style.display = columnToggles.style.display === 'none' ? 'block' : 'none'; 232 }); 233 } 234 235 if (exportButton) { 236 exportButton.addEventListener('click', function() { 237 exportToCSV(state); 238 }); 239 } 240 241 window.addEventListener('resize', function() { 242 updateStickyColumnOffsets(state); 243 }); 244} 245 246function handleSort(state, column) { 247 if (state.sortColumn === column) { 248 state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'; 249 } else { 250 state.sortColumn = column; 251 state.sortDirection = 'asc'; 252 } 253 254 sortFilteredData(state); 255 renderTable(state); 256} 257 258function handleColumnFilter(state, column, value) { 259 if (value.trim() === '') { 260 delete state.columnFilters[column]; 261 } else { 262 state.columnFilters[column] = value.trim(); 263 } 264 265 scheduleApplyFilters(state, true); 266} 267 268function scheduleApplyFilters(state, resetPage) { 269 if (!state.filterDebounceMs) { 270 applyFilters(state, resetPage); 271 return; 272 } 273 274 if (state.filterTimer) { 275 clearTimeout(state.filterTimer); 276 } 277 278 state.filterTimer = setTimeout(function() { 279 applyFilters(state, resetPage); 280 }, state.filterDebounceMs); 281} 282 283function applyFilters(state, resetPage) { 284 if (state.filterTimer) { 285 clearTimeout(state.filterTimer); 286 state.filterTimer = null; 287 } 288 289 const searchTerm = state.elements.searchInput ? state.elements.searchInput.value.toLowerCase() : ''; 290 const preparedColumnFilters = Object.entries(state.columnFilters).map(([column, filterValue]) => { 291 const numericComparison = isNumericColumn(column) ? parseNumericComparison(filterValue) : null; 292 return { 293 column, 294 filterValue: filterValue.toLowerCase(), 295 numericComparison 296 }; 297 }); 298 299 state.filteredData = state.allData.filter(row => { 300 for (const { column, filterValue, numericComparison } of preparedColumnFilters) { 301 const cellValue = getCachedCellValue(state, row, column); 302 303 if (numericComparison) { 304 if (!applyNumericFilter(cellValue, numericComparison)) { 305 return false; 306 } 307 continue; 308 } 309 310 if (!cellValue.includes(filterValue)) { 311 return false; 312 } 313 } 314 315 if (searchTerm) { 316 const rowText = getCachedRowSearchText(state, row); 317 if (!rowText.includes(searchTerm)) { 318 return false; 319 } 320 } 321 322 return true; 323 }); 324 325 if (resetPage && state.isPaginated) { 326 state.currentPage = 1; 327 } 328 329 sortFilteredData(state); 330 renderTable(state); 331} 332 333function sortFilteredData(state) { 334 if (!state.sortColumn) { 335 return; 336 } 337 338 state.filteredData.sort((a, b) => { 339 const result = compareValues(a, b, state.sortColumn); 340 return state.sortDirection === 'asc' ? result : -result; 341 }); 342} 343 344function renderTable(state) { 345 const { tbody } = state.elements; 346 tbody.innerHTML = ''; 347 348 if (state.allColumns.length === 0) { 349 showTableMessage(state, state.noDataMessage); 350 updateStats(state); 351 return; 352 } 353 354 const rowsToRender = getRowsForCurrentPage(state); 355 356 if (rowsToRender.length === 0) { 357 showTableMessage(state, state.emptyMessage, state.allColumns.length); 358 } else { 359 rowsToRender.forEach(row => { 360 const tr = document.createElement('tr'); 361 362 state.allColumns.forEach((column, index) => { 363 const td = document.createElement('td'); 364 td.dataset.column = column; 365 td.dataset.columnIndex = index; 366 367 if (isLinkColumn(column)) { 368 td.innerHTML = createLinkButtons(row[column]); 369 } else if (shouldUseExpandableCell(state, column, row[column])) { 370 td.appendChild(createExpandableCellContent(row[c
370olumn])); 371 } else { 372 td.textContent = formatCellValue(row[column]); 373 } 374 375 tr.appendChild(td); 376 }); 377 378 tbody.appendChild(tr); 379 }); 380 } 381 382 applyColumnVisibility(state); 383 updateSortIndicators(state); 384 updateStats(state); 385 renderPaginationControls(state); 386 updateStickyColumnOffsets(state); 387} 388 389function getRowsForCurrentPage(state) { 390 if (!state.isPaginated) { 391 return state.filteredData; 392 } 393 394 const totalRows = state.filteredData.length; 395 if (totalRows === 0) { 396 return []; 397 } 398 399 const totalPages = Math.max(1, Math.ceil(totalRows / state.pageSize)); 400 if (state.currentPage > totalPages) { 401 state.currentPage = totalPages; 402 } 403 404 const startIndex = (state.currentPage - 1) * state.pageSize; 405 const endIndex = Math.min(startIndex + state.pageSize, totalRows); 406 return state.filteredData.slice(startIndex, endIndex); 407} 408 409function renderPaginationControls(state) { 410 const container = state.elements.paginationContainer; 411 if (!state.isPaginated || !container) { 412 return; 413 } 414 415 const totalRows = state.filteredData.length; 416 const totalPages = Math.max(1, Math.ceil(totalRows / state.pageSize)); 417 if (state.currentPage > totalPages) { 418 state.currentPage = totalPages; 419 } 420 421 const start = totalRows === 0 ? 0 : (state.currentPage - 1) * state.pageSize + 1; 422 const end = Math.min(state.currentPage * state.pageSize, totalRows); 423 424 container.innerHTML = [ 425 `<div class="table-pagination-summary">Rows ${start}-${end} of ${totalRows}</div>`, 426 '<div class="table-pagination-controls">', 427 ` <label for="${state.itemLabel}-page-size">Rows per page</label>`, 428 ` <select id="${state.itemLabel}-page-size">`, 429 ` ${[100, 200, 500, 1000].map(size => `<option value="${size}"${size === state.pageSize ? ' selected' : ''}>${size}</option>`).join('')}`, 430 ' </select>', 431 ` <button type="button" class="btn-secondary table-page-btn" data-action="prev"${state.currentPage <= 1 ? ' disabled' : ''}>Prev</button>`, 432 ` <span class="table-pagination-page">Page ${state.currentPage} / ${totalPages}</span>`, 433 ` <button type="button" class="btn-secondary table-page-btn" data-action="next"${state.currentPage >= totalPages ? ' disabled' : ''}>Next</button>`, 434 '</div>' 435 ].join(''); 436 437 const pageSizeSelect = container.querySelector(`#${state.itemLabel}-page-size`); 438 if (pageSizeSelect) { 439 pageSizeSelect.addEventListener('change', function(event) { 440 const nextPageSize = parseInt(event.target.value, 10); 441 if (!Number.isNaN(nextPageSize) && nextPageSize > 0) { 442 state.pageSize = nextPageSize; 443 state.currentPage = 1; 444 renderTable(state); 445 } 446 }); 447 } 448 449 Array.from(container.querySelectorAll('.table-page-btn')).forEach(button => { 450 button.addEventListener('click', function(event) { 451 const action = event.target.dataset.action; 452 if (action === 'prev' && state.currentPage > 1) { 453 state.currentPage -= 1; 454 renderTable(state); 455 } 456 457 if (action === 'next' && state.currentPage < totalPages) { 458 state.currentPage += 1; 459 renderTable(state); 460 } 461 }); 462 }); 463} 464 465function applyColumnVisibility(state) { 466 const { headerRow, filterRow, tbody } = state.elements; 467 468 Array.from(headerRow.children).forEach((th, index) => { 469 toggleColumnVisibility(th, state.visibleColumns.has(state.allColumns[index])); 470 }); 471 472 Array.from(filterRow.children).forEach((td, index) => { 473 toggleColumnVisibility(td, state.visibleColumns.has(state.allColumns[index])); 474 }); 475 476 Array.from(tbody.children).forEach(tr => { 477 Array.from(tr.children).forEach((td, index) => { 478 toggleColumnVisibility(td, state.visibleColumns.has(state.allColumns[index])); 479 }); 480 }); 481} 482 483function toggleColumnVisibility(element, isVisible) { 484 if (!element) { 485 return; 486 } 487 488 if (isVisible) { 489 element.classList.remove('column-hidden'); 490 } else { 491 element.classList.add('column-hidden'); 492 } 493} 494 495function updateStickyColumnOffsets(state) { 496 const { headerRow, tbody } = state.elements; 497 const firstHeaderCell = headerRow ? headerRow.children[0] : null; 498 const secondHeaderCell = headerRow ? headerRow.children[1] : null; 499 const firstColumnWidth = firstHeaderCell && !firstHeaderCell.classList.contains('column-hidden') 500 ? firstHeaderCell.offsetWidth 501 : 0; 502 const secondColumnWidth = secondHeaderCell && !secondHeaderCell.classList.contains('column-hidden') 503 ? secondHeaderCell.offsetWidth 504 : 0; 505 506 const table = headerRow ? headerRow.closest('table') : null; 507 if (!table) { 508 return; 509 } 510 511 table.style.setProperty('--sticky-first-column-width', `${firstColumnWidth}px`); 512 table.style.setProperty('--sticky-second-column-width', `${secondColumnWidth}px`); 513 514 if (tbody && tbody.children.length > 0) { 515 const firstRow = tbody.children[0]; 516 const firstCell = firstRow.children[0]; 517 const secondCell = firstRow.children[1]; 518 519 if (firstCell && !firstCell.classList.contains('column-hidden')) { 520 table.style.setProperty('--sticky-first-column-width', `${firstCell.offsetWidth}px`); 521 } 522 523 if (secondCell && !secondCell.classList.contains('column-hidden')) { 524 table.style.setProperty('--sticky-second-column-width', `${secondCell.offsetWidth}px`); 525 } 526 } 527} 528 529function updateSortIndicators(state) { 530 const { headerRow } = state.elements; 531
532 Array.from(headerRow.children).forEach(th => { 533 th.classList.remove('sort-asc', 'sort-desc', 'sort-active'); 534 if (th.dataset.column === state.sortColumn) { 535 th.classList.add('sort-active'); 536 th.classList.add(state.sortDirection === 'asc' ? 'sort-asc' : 'sort-desc'); 537 } 538 }); 539} 540 541function updateStats(state) { 542 const { countElement } = state.elements; 543 if (!countElement) { 544 return; 545 } 546 547 if (state.allColumns.length === 0) { 548 countElement.textContent = `No ${state.itemLabelPlural} available`; 549 return; 550 } 551 552 if (state.isPaginated) { 553 const totalFiltered = state.filteredData.length; 554 if (totalFiltered === 0) { 555 countElement.textContent = `Showing 0 of ${state.allData.length} ${state.itemLabelPlural}`; 556 return; 557 } 558 559 const start = (state.currentPage - 1) * state.pageSize + 1; 560 const end = Math.min(state.currentPage * state.pageSize, totalFiltered); 561 countElement.textContent = `Showing ${start}-${end} of ${totalFiltered} filtered (${state.allData.length} total ${state.itemLabelPlural})`; 562 return; 563 } 564 565 countElement.textContent = `Showing ${state.filteredData.length} of ${state.allData.length} ${state.itemLabelPlural}`; 566} 567 568function showTableMessage(state, message, colspan) { 569 const tr = document.createElement('tr'); 570 const td = document.createElement('td'); 571 td.className = 'empty-state'; 572 td.textContent = message; 573 td.colSpan = colspan || 1; 574 tr.appendChild(td); 575 state.elements.tbody.appendChild(tr); 576} 577 578function exportToCSV(state) { 579 if (state.filteredData.length === 0) { 580 alert(`No ${state.itemLabelPlural} to export. Please adjust your filters.`); 581 return; 582 } 583 584 const columnsToExport = state.allColumns.filter(column => state.visibleColumns.has(column)); 585 let csvContent = ''; 586 587 csvContent += columnsToExport.map(column => `"${column}"`).join(',') + '\n'; 588 589 state.filteredData.forEach(row => { 590 const rowData = columnsToExport.map(column => { 591 let value = formatCellValue(row[column]).replace(/"/g, '""'); 592 return `"${value}"`; 593 }); 594 csvContent += rowData.join(',') + '\n'; 595 }); 596 597 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); 598 const link = document.createElement('a'); 599 const url = URL.createObjectURL(blob); 600 601 link.setAttribute('href', url); 602 link.setAttribute('download', `${state.exportFilePrefix}_${new Date().toISOString().split('T')[0]}.csv`); 603 link.style.visibility = 'hidden'; 604 605 document.body.appendChild(link); 606 link.click(); 607 document.body.removeChild(link); 608 URL.revokeObjectURL(url); 609} 610 611function normalizeSortValue(value) { 612 if (value === null || value === undefined) { 613 return ''; 614 } 615 return String(value).trim(); 616} 617 618function compareValues(a, b, column) { 619 const valueA = normalizeSortValue(a[column]); 620 const valueB = normalizeSortValue(b[column]); 621 622 if (valueA === '' && valueB === '') { 623 return 0; 624 } 625 if (valueA === '') { 626 return 1; 627 } 628 if (valueB === '') { 629 return -1; 630 } 631 632 const numA = parseFloat(valueA); 633 const numB = parseFloat(valueB); 634 const bothNumeric = !Number.isNaN(numA) && !Number.isNaN(numB) && /^-?\d+(\.\d+)?$/.test(valueA) && /^-?\d+(\.\d+)?$/.test(valueB); 635 636 if (bothNumeric) { 637 return numA - numB; 638 } 639 640 return valueA.localeCompare(valueB, undefined, { 641 numeric: true, 642 sensitivity: 'base' 643 }); 644} 645 646function formatCellValue(value) { 647 return value === null || value === undefined ? '' : String(value); 648} 649 650function getCachedCellValue(state, row, column) { 651 let rowCache = state.cellValueCache.get(row); 652 if (!rowCache) { 653 rowCache = {}; 654 state.cellValueCache.set(row, rowCache); 655 } 656 657 if (!(column in rowCache)) { 658 rowCache[column] = formatCellValue(row[column]).toLowerCase(); 659 } 660 661 return rowCache[column]; 662} 663 664function getCachedRowSearchText(state, row) {
665 let cachedText = state.rowSearchCache.get(row); 666 if (cachedText !== undefined) { 667 return cachedText; 668 } 669 670 cachedText = Object.values(row).map(formatCellValue).join(' ').toLowerCase(); 671 state.rowSearchCache.set(row, cachedText); 672 return cachedText; 673} 674 675function isLinkColumn(column) { 676 return ['Access Link', 'Related Paper', 'URL', 'Paper'].includes(column); 677} 678 679function parseNumericComparison(filterValue) { 680 const operators = [ 681 { regex: /^>=\s*(.+)/, op: '>=' }, 682 { regex: /^<=\s*(.+)/, op: '<=' }, 683 { regex: /^>\s*(.+)/, op: '>' }, 684 { regex: /^<\s*(.+)/, op: '<' }, 685 { regex: /^=\s*(.+)/, op: '=' } 686 ]; 687 688 for (const { regex, op } of operators) { 689 const match = filterValue.match(regex); 690 if (match) { 691 const value = parseFloat(match[1]); 692 if (!Number.isNaN(value)) { 693 return { operator: op, value: value }; 694 } 695 } 696 } 697 698 return null; 699} 700 701function applyNumericFilter(cellValue, comparison) { 702 const numericValue = parseFloat(cellValue); 703 if (Number.isNaN(numericValue)) { 704 return false; 705 } 706 707 switch (comparison.operator) { 708 case '>': 709 return numericValue > comparison.value; 710 case '<': 711 return numericValue < comparison.value; 712 case '>=': 713 return numericValue >= comparison.value; 714 case '<=': 715 return numericValue <= comparison.value; 716 case '=': 717 return numericValue === comparison.value; 718 default: 719 return false; 720 } 721} 722 723function isNumericColumn(columnName) { 724 const normalizedColumnWords = columnName 725 .toLowerCase() 726 .replace(/[^a-z0-9]+/g, ' ') 727 .trim() 728 .split(/\s+/) 729 .filter(Boolean); 730 731 return normalizedColumnWords.some(word => NUMERIC_COLUMN_TOKENS.includes(word)); 732} 733 734function shouldUseExpandableCell(state, column, value) { 735 if (state.itemLabel !== 'subject') { 736 return false; 737 } 738 739 if (!['Segmentation Available', 'Mesh Available', 'NURBS Available'].includes(column)) { 740 return false; 741 } 742 743 return getExpandableItems(value).length > 0; 744} 745 746function createExpandableCellContent(value) { 747 const items = getExpandableItems(value); 748 if (items.length === 0) { 749 return document.createTextNode(''); 750 } 751 752 const details = document.createElement('details'); 753 details.className = 'expandable-cell'; 754 755 const summary = document.createElement('summary'); 756 summary.className = 'expandable-cell-toggle'; 757 summary.textContent = `Show ${items.length} item${items.length === 1 ? '' : 's'}`; 758 759 const content = document.createElement('div'); 760 content.className = 'expandable-cell-content'; 761 762 const list = document.createElement('ul'); 763 list.className = 'expandable-cell-list'; 764 765 items.forEach(item => { 766 const listItem = document.createElement('li'); 767 listItem.textContent = item; 768 list.appendChild(listItem); 769 }); 770 771 content.appendChild(list); 772 details.appendChild(summary); 773 details.appendChild(content); 774 775 return details; 776} 777 778function getExpandableItems(value) { 779 const items = formatCellValue(value) 780 .split(';') 781 .map(item => item.trim()) 782 .filter(Boolean); 783 784 // Natural sort: numeric-aware, case-insensitive 785 items.sort((a, b) => { 786 return a.localeCompare(b, undefined, { 787 numeric: true, 788 sensitivity: 'base' 789 }); 790 }); 791 792 return items; 793} 794 795function createLinkButtons(linkString) { 796 if (!linkString || !String(linkString).trim()) { 797 return ''; 798 } 799 800 const links = String(linkString).split(';').map(link => link.trim()).filter(Boolean); 801 if (links.length === 0) { 802 return ''; 803 } 804 805 return links.map((link, index) => { 806 const buttonText = links.length === 1 ? 'Link' : `Link ${index + 1}`; 807 const trackedLink = addBonehubReferral(link); 808 return `<a href="${trackedLink}" target="_blank" rel="noopener noreferrer" class="btn-link">${buttonText}</a>`; 809 }).join(' '); 810} 811 812function addBonehubReferral(link) { 813 try { 814 const url = new URL(link); 815 if (url.protocol === 'http:' || url.protocol === 'https:') { 816 if (!url.searchParams.has('utm_source')) { 817 url.searchParams.set('utm_source', 'bonehub'); 818 } 819 return url.toString(); 820 } 821 } catch (error) { 822 return link; 823 } 824 825 return link; 826} 827 828function slugify(value) { 829 return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); 830}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.