feat(events): event modal + sortable tables polish

Event modal (event-modal.js):
- Record Type now derived from Blastware filename's last-char code
  (H=Histogram, W=Waveform, M=Manual, E=Event, C=Combo).  Falls back to
  whatever SFM reported if the code isn't recognized.  Client-side
  workaround — SFM still hardcodes "Waveform" server-side and needs a
  proper fix in its sidecar parser.
- PSI mic tile dropped; mic section now renders 3 tiles (dB(L), ZC
  Frequency, Time of Peak) instead of 4.
- New "View JSON" toggle exposes a prettified inline JSON viewer with
  a Copy-to-clipboard button alongside the existing "Download sidecar
  JSON" link.
- "Project Info" section header renamed to "User Notes" to reflect
  that these are operator-typed fields, not the terra-view project
  assignment.

Sortable tables (sfm.html + unit_detail.html):
- Both Events tables now have clickable column headers with ↕/↓/↑
  indicators.  Default sort is Timestamp DESC.  Clicking the same
  column toggles direction; clicking a different column switches and
  resets to DESC.  Sort is purely client-side over the cached rowset,
  so no extra fetches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 17:53:28 +00:00
parent 583af1948e
commit 155f0b007a
3 changed files with 203 additions and 28 deletions
+68 -9
View File
@@ -2142,6 +2142,15 @@ function clearUnitEventFilters() {
loadUnitEvents();
}
// Module-level state for the unit-events table sort. Cache lets us re-sort
// without a refetch when the user clicks a column header.
let _ueEventsCache = [];
let _ueEventsTotal = 0;
let _ueEventsBucket = 'all';
let _ueAssignmentsTotal = 0;
let _ueSortKey = 'timestamp';
let _ueSortDir = 'desc';
async function loadUnitEvents() {
if (!currentUnit || currentUnit.device_type !== 'seismograph') return;
const container = document.getElementById('ue-events-container');
@@ -2166,13 +2175,62 @@ async function loadUnitEvents() {
throw new Error(err.detail || 'HTTP ' + r.status);
}
const d = await r.json();
_ueEventsCache = d.events || [];
_ueEventsTotal = d.count || 0;
_ueEventsBucket = bucket;
_ueAssignmentsTotal = d.assignments_total || 0;
renderUnitEventStats(d.stats);
renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total);
renderUnitEventTable(_ueEventsCache, _ueEventsTotal, container, bucket, _ueAssignmentsTotal);
} catch (e) {
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
}
}
function sortUnitEvents(key) {
if (_ueSortKey === key) {
_ueSortDir = _ueSortDir === 'desc' ? 'asc' : 'desc';
} else {
_ueSortKey = key;
_ueSortDir = 'desc';
}
renderUnitEventTable(_ueEventsCache, _ueEventsTotal,
document.getElementById('ue-events-container'), _ueEventsBucket, _ueAssignmentsTotal);
}
function _ueApplySort(events) {
const key = _ueSortKey;
const dir = _ueSortDir === 'asc' ? 1 : -1;
return [...events].sort((a, b) => {
let av, bv;
if (key === 'attribution') {
// Sort by location name so attributed rows group together.
av = a.attribution ? (a.attribution.location_name || '') : '';
bv = b.attribution ? (b.attribution.location_name || '') : '';
} else {
av = a[key]; bv = b[key];
}
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
}
function _ueSortIndicator(key) {
if (_ueSortKey !== key) return '<span class="text-gray-400 opacity-50 ml-1">↕</span>';
return _ueSortDir === 'desc'
? '<span class="text-seismo-orange ml-1">↓</span>'
: '<span class="text-seismo-orange ml-1">↑</span>';
}
function _ueSortableTh(label, key) {
return `<th onclick="sortUnitEvents('${key}')"
class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
${label}${_ueSortIndicator(key)}
</th>`;
}
function renderUnitEventStats(stats) {
const s = stats || {};
document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString();
@@ -2269,7 +2327,8 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
return;
}
const rows = events.map(ev => {
const sorted = _ueApplySort(events);
const rows = sorted.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const tran = _ueFmtPPV(ev.tran_ppv);
const vert = _ueFmtPPV(ev.vert_ppv);
@@ -2295,13 +2354,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th>
${_ueSortableTh('Timestamp', 'timestamp')}
${_ueSortableTh('Tran', 'tran_ppv')}
${_ueSortableTh('Vert', 'vert_ppv')}
${_ueSortableTh('Long', 'long_ppv')}
${_ueSortableTh('PVS', 'peak_vector_sum')}
${_ueSortableTh('Flags', 'false_trigger')}
${_ueSortableTh('Attribution', 'attribution')}
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>