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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user