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
+76 -8
View File
@@ -62,6 +62,27 @@
</div>`;
}
function _deriveRecordType(filename, fallback) {
// SFM currently hardcodes record_type="Waveform" for every event.
// The actual type is encoded in the LAST character of the Blastware
// filename's extension (e.g. "O121LL5E.IS0H" → "H" → Histogram).
// We derive it client-side until SFM is fixed; if the suffix isn't
// a known code we fall back to whatever SFM reported.
if (!filename) return fallback || '—';
const dotIdx = filename.lastIndexOf('.');
if (dotIdx < 0 || dotIdx === filename.length - 1) return fallback || '—';
const ext = filename.slice(dotIdx + 1);
const lastChar = ext.slice(-1).toUpperCase();
const typeMap = {
'H': 'Histogram',
'W': 'Waveform',
'M': 'Manual',
'E': 'Event',
'C': 'Combo',
};
return typeMap[lastChar] || (fallback || '—');
}
function _sectionHeader(title, sub) {
return `<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-5 first:mt-0">
${_esc(title)}${sub ? ` <span class="text-xs text-gray-400 normal-case font-normal ml-2">${_esc(sub)}</span>` : ''}
@@ -72,21 +93,23 @@
function _renderEventHeader(s) {
const ev = s.event || {};
const bw = s.blastware || {};
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const recType = _deriveRecordType(bw.filename || ev.blastware_filename, ev.record_type);
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Serial</span> <span class="font-mono font-semibold text-seismo-orange ml-1">${_esc(ev.serial)}</span></div>
<div><span class="text-gray-500">Timestamp</span> <span class="font-medium ml-1">${ts}</span></div>
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(ev.record_type || '—')}</span></div>
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(recType)}</span></div>
<div><span class="text-gray-500">Sample Rate</span> <span class="font-medium ml-1">${ev.sample_rate ?? '—'} sps</span></div>
<div><span class="text-gray-500">Rec Time</span> <span class="font-medium ml-1">${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}</span></div>
<div><span class="text-gray-500">Waveform Key</span> <span class="font-mono text-xs ml-1">${_esc(ev.waveform_key || '—')}</span></div>
</div>`;
}
function _renderProjectInfo(s) {
function _renderUserNotes(s) {
// The "user notes" metadata the operator typed into the BW device.
// These are the strings the future metadata-driven parser will use.
const p = s.project_info || {};
const p = s.user_notes || {};
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '—')}</span></div>
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '—')}</span></div>
@@ -120,20 +143,21 @@
}
function _renderMic(s) {
// Operators only care about dB(L); PSI tile was dropped 2026-05.
// We still render the row if any mic data is present so ZC freq /
// time-of-peak stay visible even when bw_report.mic is missing.
const mic = (s.bw_report && s.bw_report.mic) || null;
const pv = s.peak_values || {};
if (!mic && pv.mic_psi == null) return '';
const dbl = mic?.pspl_dbl;
const psi = pv.mic_psi;
const zcHz = mic?.zc_freq_hz;
const tPk = mic?.time_of_peak_s;
const wt = mic?.weighting;
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })}
${_kvCard('Peak Mic psi', _fmt(psi, 4))}
${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))}
${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
</div>`;
@@ -223,6 +247,14 @@
Blastware file unavailable
</span>
`}
<button type="button"
onclick="window.toggleEventJsonViewer()"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
<span id="event-json-toggle-label">View JSON</span>
</button>
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar"
download="${_esc((bw.filename || 'event') + '.sfm.json')}"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
@@ -232,6 +264,16 @@
Download sidecar JSON
</a>
</div>
<div id="event-json-viewer" class="hidden mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
<button type="button" onclick="window.copyEventJson()"
class="text-xs text-seismo-orange hover:text-seismo-navy">
<span id="event-json-copy-label">Copy</span>
</button>
</div>
<pre id="event-json-pre" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-4 rounded-lg max-h-96 overflow-auto whitespace-pre">${_esc(JSON.stringify(s, null, 2))}</pre>
</div>
`;
return `${downloadButtons}
@@ -294,8 +336,8 @@
${_sectionHeader('Event')}
${_renderEventHeader(s)}
${_sectionHeader('Project Info', '(operator-typed at session start)')}
${_renderProjectInfo(s)}
${_sectionHeader('User Notes')}
${_renderUserNotes(s)}
${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)}
@@ -323,6 +365,32 @@
if (modal) modal.classList.add('hidden');
};
window.toggleEventJsonViewer = function () {
const viewer = document.getElementById('event-json-viewer');
const label = document.getElementById('event-json-toggle-label');
if (!viewer) return;
const isHidden = viewer.classList.toggle('hidden');
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
};
window.copyEventJson = function () {
const pre = document.getElementById('event-json-pre');
const label = document.getElementById('event-json-copy-label');
if (!pre) return;
navigator.clipboard.writeText(pre.textContent).then(() => {
if (label) {
label.textContent = 'Copied!';
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
}
}).catch(err => {
console.error('clipboard write failed', err);
if (label) {
label.textContent = 'Failed';
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
}
});
};
// Close on Escape.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.closeEventDetailModal();