Files
meshcore-analyzer/public/table-sort.js
Kpa-clawbot 6f3e3535c9 feat: shared table sort utility + packets table sorting (M1, #620) (#638)
## Summary

Implements M1 of the table sorting spec (#620): a shared `TableSort`
utility module and integration with the packets table.

### What's included

**1. `public/table-sort.js` — Shared sort utility (IIFE, no
dependencies)**
- `TableSort.init(tableEl, options)` — attaches click-to-sort on `<th
data-sort-key="...">` elements
- Built-in comparators: text (localeCompare), numeric, date (ISO), dBm
(strips suffix)
- NaN/null values sort last consistently
- Visual: ▲/▼ `<span class="sort-arrow">` appended to active column
header
- Accessibility: `aria-sort="ascending|descending|none"`, keyboard
support (Enter/Space)
- DOM reorder via `appendChild` loop (no innerHTML rebuild)
- `domReorder: false` option for virtual scroll tables (packets)
- `storageKey` option for localStorage persistence
- Custom comparator override per column
- `onSort(column, direction)` callback
- `destroy()` for clean teardown

**2. Packets table integration**
- All columns sortable: region, time, hash, size, HB, type, observer,
path, rpt
- Default sort: time descending (matches existing behavior)
- Uses `domReorder: false` + `onSort` callback to sort the data array,
then re-render via virtual scroll
- Works with both grouped and ungrouped views
- WebSocket updates respect active sort column
- Sort preference persisted in localStorage (`meshcore-packets-sort`)

**3. Tests — 22 unit tests (`test-table-sort.js`)**
- All 4 built-in comparators (text, numeric, date, dBm)
- NaN/null edge cases
- Direction toggle on click
- aria-sort attribute correctness
- Visual indicator (▲/▼) presence and updates
- onSort callback
- domReorder: false behavior
- destroy() cleanup
- Custom comparator override

### Performance

Packets table sorting works at the data array level (single `Array.sort`
call), not DOM level. Virtual scroll then renders only visible rows. No
new DOM nodes are created during sort — it's purely a data reorder +
re-render of the existing visible window. Expected sort time for 30K
packets: ~50-100ms (array sort) + existing virtual scroll render time.

Closes #620 (M1)

Co-authored-by: you <you@example.com>
2026-04-05 15:29:14 -07:00

225 lines
7.3 KiB
JavaScript

/* === CoreScope — table-sort.js === */
/* Shared table sorting utility. IIFE, no dependencies. */
'use strict';
window.TableSort = (function() {
/**
* Built-in comparators. Each takes two raw string values (from data-value or textContent)
* and returns a number for Array.sort.
*/
var comparators = {
text: function(a, b) {
if (a == null) a = '';
if (b == null) b = '';
return String(a).localeCompare(String(b));
},
numeric: function(a, b) {
var na = Number(a), nb = Number(b);
var aIsNaN = isNaN(na), bIsNaN = isNaN(nb);
if (aIsNaN && bIsNaN) return 0;
if (aIsNaN) return 1; // NaN sorts last
if (bIsNaN) return -1;
return na - nb;
},
date: function(a, b) {
var ta = a ? new Date(a).getTime() : NaN;
var tb = b ? new Date(b).getTime() : NaN;
var aIsNaN = isNaN(ta), bIsNaN = isNaN(tb);
if (aIsNaN && bIsNaN) return 0;
if (aIsNaN) return 1;
if (bIsNaN) return -1;
return ta - tb;
},
dbm: function(a, b) {
var na = parseFloat(String(a).replace(/\s*dBm\s*/i, ''));
var nb = parseFloat(String(b).replace(/\s*dBm\s*/i, ''));
var aIsNaN = isNaN(na), bIsNaN = isNaN(nb);
if (aIsNaN && bIsNaN) return 0;
if (aIsNaN) return 1;
if (bIsNaN) return -1;
return na - nb;
}
};
/**
* Resolve the comparator for a <th> element.
* Priority: custom comparator from options > data-type attribute > text default.
*/
function resolveComparator(key, thEl, customComparators) {
if (customComparators && customComparators[key]) return customComparators[key];
var type = thEl.getAttribute('data-type');
if (type && comparators[type]) return comparators[type];
return comparators.text;
}
/**
* Get the sort value for a <td>. Prefers data-value attribute, falls back to textContent.
*/
function getCellValue(td) {
if (!td) return '';
var dv = td.getAttribute('data-value');
return dv != null ? dv : td.textContent.trim();
}
/**
* Initialize sorting on a table element.
*
* @param {HTMLTableElement} tableEl - The table to make sortable
* @param {Object} [options]
* @param {string} [options.defaultColumn] - data-sort-key of initial sort column
* @param {string} [options.defaultDirection='asc'] - 'asc' or 'desc'
* @param {string} [options.storageKey] - localStorage key for persistence
* @param {Object} [options.comparators] - custom comparator functions keyed by column key
* @param {Function} [options.onSort] - callback(column, direction) after sort
* @param {boolean} [options.domReorder=true] - if false, skip DOM reorder (for virtual scroll tables)
* @returns {Object} instance with sort(), destroy(), getState() methods
*/
function init(tableEl, options) {
if (!tableEl) return null;
options = options || {};
var thead = tableEl.querySelector('thead');
if (!thead) return null;
var state = { column: options.defaultColumn || null, direction: options.defaultDirection || 'asc' };
var domReorder = options.domReorder !== false;
// Restore from localStorage
if (options.storageKey) {
try {
var saved = JSON.parse(localStorage.getItem(options.storageKey));
if (saved && saved.column) {
state.column = saved.column;
state.direction = saved.direction || 'asc';
}
} catch(e) { /* ignore */ }
}
var ths = thead.querySelectorAll('th[data-sort-key]');
var thMap = {}; // key → th element
var handlers = []; // for cleanup
for (var i = 0; i < ths.length; i++) {
(function(th) {
var key = th.getAttribute('data-sort-key');
thMap[key] = th;
th.style.cursor = 'pointer';
th.setAttribute('tabindex', '0');
th.setAttribute('aria-sort', 'none');
var handler = function(e) {
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
if (state.column === key) {
state.direction = state.direction === 'asc' ? 'desc' : 'asc';
} else {
state.column = key;
state.direction = options.defaultDirection || 'asc';
}
doSort();
};
th.addEventListener('click', handler);
th.addEventListener('keydown', handler);
handlers.push({ el: th, click: handler, keydown: handler });
})(ths[i]);
}
// Apply initial sort if defaultColumn is set
if (state.column && thMap[state.column]) {
updateArrows();
if (domReorder) sortDOM();
}
function doSort() {
updateArrows();
if (options.storageKey) {
try { localStorage.setItem(options.storageKey, JSON.stringify(state)); } catch(e) { /* ignore */ }
}
if (domReorder) sortDOM();
if (options.onSort) options.onSort(state.column, state.direction);
}
function updateArrows() {
for (var k in thMap) {
var th = thMap[k];
// Remove existing arrow
var arrow = th.querySelector('.sort-arrow');
if (arrow) arrow.remove();
if (k === state.column) {
th.classList.add('sort-active');
th.setAttribute('aria-sort', state.direction === 'asc' ? 'ascending' : 'descending');
var span = document.createElement('span');
span.className = 'sort-arrow';
span.textContent = state.direction === 'asc' ? ' ▲' : ' ▼';
th.appendChild(span);
} else {
th.classList.remove('sort-active');
th.setAttribute('aria-sort', 'none');
}
}
}
function sortDOM() {
var tbody = tableEl.querySelector('tbody');
if (!tbody) return;
var th = thMap[state.column];
if (!th) return;
var cmp = resolveComparator(state.column, th, options.comparators);
var colIndex = -1;
var allThs = thead.querySelectorAll('th');
for (var j = 0; j < allThs.length; j++) {
if (allThs[j] === th) { colIndex = j; break; }
}
if (colIndex < 0) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
var dir = state.direction === 'asc' ? 1 : -1;
rows.sort(function(rowA, rowB) {
var a = getCellValue(rowA.cells[colIndex]);
var b = getCellValue(rowB.cells[colIndex]);
return dir * cmp(a, b);
});
// DOM reorder via appendChild (no innerHTML rebuild)
for (var r = 0; r < rows.length; r++) {
tbody.appendChild(rows[r]);
}
}
function destroy() {
for (var h = 0; h < handlers.length; h++) {
handlers[h].el.removeEventListener('click', handlers[h].click);
handlers[h].el.removeEventListener('keydown', handlers[h].keydown);
// Clean up aria/classes
handlers[h].el.removeAttribute('aria-sort');
handlers[h].el.classList.remove('sort-active');
var arrow = handlers[h].el.querySelector('.sort-arrow');
if (arrow) arrow.remove();
}
handlers = [];
}
function sort(column, direction) {
if (column) state.column = column;
if (direction) state.direction = direction;
doSort();
}
function getState() {
return { column: state.column, direction: state.direction };
}
return { sort: sort, destroy: destroy, getState: getState };
}
return {
init: init,
comparators: comparators
};
})();