PageSourceSearch

https://bonehub.github.io/BoneHub-Dataset/app.js

js bonehub.github.io collected 2026-10-03 08:43:39 UTC 26,220 bytes, 830 lines download raw bytes

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.