sfm_webapp: default to Database view + sortable columns + inline waveform plot
Three UX upgrades to the main SFM webapp at /, all reinforcing the
'browse stored events' flow as the primary entry point:
1. Default section is now Database, not Live Device. Most users land
here to look at stored events; Live Device is opt-in (click the tab
to talk to a unit). Initial history + units fetch fires on first
paint so the table is populated when the page loads.
2. History table columns are sortable. Click any header to sort:
timestamp, serial, per-channel PPV (Tran/Vert/Long), PVS, mic dB(L),
project, client, type, key. Default direction varies by column type
(desc for numbers + timestamps, asc for text). Sort arrows appear
in the active column header. Headers are sticky so they stay
visible while scrolling.
3. Click-event-to-see-waveform. The existing sidecar review modal now
renders the 4-channel waveform plot inline at the top, fetched from
/db/events/{id}/waveform.json in parallel with the sidecar fetch.
Channels stacked MicL / Long / Vert / Tran (Instantel printout
order), shared bottom time axis, dashed trigger line + triangle
markers at t=0, zero baseline with "0.0" label on the right edge,
peak callouts per channel. Charts cleaned up on modal close.
Resolves the "where is the viewer" surprise — operators no longer need
to know about the /events route to see waveforms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -8,7 +8,8 @@ All notable changes to seismo-relay are documented here.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Stored-event browser** — new standalone HTML page at `GET /events` (`sfm/event_browser.html`). Pick a serial from the unit dropdown, scroll through that unit's events (newest-first), click any event to render its decoded waveform via the existing `/db/events/{id}/waveform.json` endpoint. Dark-themed Chart.js viewer, channels stacked vertically (Tran / Vert / Long / MicL — Instantel printout order, designed PDF-export-ready), trigger line at t=0, peak labels, search/filter, false-trigger flag honored. Companion to the existing live-device viewer at `/waveform`; the two routes are now clearly delineated in their docstrings.
|
- **SFM webapp now opens to Database view by default** and the History table is fully interactive. Click any column header to sort ascending / descending (timestamp, serial, per-channel PPV, PVS, mic dB(L), project, client, record type, key — all sortable). Click any event row to open the event modal, which now renders a **4-channel waveform plot inline** (MicL / Long / Vert / Tran stacked, Instantel-printout order) alongside the existing sidecar review fields. Headers are sticky so the columns stay visible while scrolling long event lists. No more "where is the viewer" — pick a unit from the filter dropdown, scan the table, click the event, see the waveform.
|
||||||
|
- **Stored-event browser** — new standalone HTML page at `GET /events` (`sfm/event_browser.html`). Pick a serial from the unit dropdown, scroll through that unit's events (newest-first), click any event to render its decoded waveform via the existing `/db/events/{id}/waveform.json` endpoint. Dark-themed Chart.js viewer, channels stacked vertically (MicL / Long / Vert / Tran — Instantel printout order, designed PDF-export-ready), trigger line at t=0, peak labels, search/filter, false-trigger flag honored. Companion to the existing live-device viewer at `/waveform`; the two routes are now clearly delineated in their docstrings. The webapp's inline plot at `/` is the primary path; `/events` remains a useful diagnostic when you want just a viewer.
|
||||||
- **Histogram body codec — uint8 peak count fix.** Per-channel peak fields at `block[6]/[10]/[14]/[18]` are `uint8`, not `uint16 LE` spanning `block[6:8]` etc. The original interpretation was byte-exact on the N844 fixture corpus only because every annotation byte (`block[7]/[11]/[15]/[19]`) in those fixtures was zero. On non-N844 events with non-zero annotation bytes (observed across BE9558 Tran-drift and BE18003 Histogram+Continuous units), the old interpretation produced peaks up to 268 in/s per channel and 35× inflated PVS sums when first deployed to prod (rolled back same day; properly fixed in this release). Cross-correlated against BW's per-interval ASCII export on K558 / T003 / N599 / N844 corpora — 100% byte-exact on T/V/L, 99%+ on M (sub-precision rounding). Annotation byte preserved on each record as `record["annotations"]` for future RE. Verified against ~3,500 blocks across 5 in-repo fixtures + a synthetic K558 interval-12 regression block.
|
- **Histogram body codec — uint8 peak count fix.** Per-channel peak fields at `block[6]/[10]/[14]/[18]` are `uint8`, not `uint16 LE` spanning `block[6:8]` etc. The original interpretation was byte-exact on the N844 fixture corpus only because every annotation byte (`block[7]/[11]/[15]/[19]`) in those fixtures was zero. On non-N844 events with non-zero annotation bytes (observed across BE9558 Tran-drift and BE18003 Histogram+Continuous units), the old interpretation produced peaks up to 268 in/s per channel and 35× inflated PVS sums when first deployed to prod (rolled back same day; properly fixed in this release). Cross-correlated against BW's per-interval ASCII export on K558 / T003 / N599 / N844 corpora — 100% byte-exact on T/V/L, 99%+ on M (sub-precision rounding). Annotation byte preserved on each record as `record["annotations"]` for future RE. Verified against ~3,500 blocks across 5 in-repo fixtures + a synthetic K558 interval-12 regression block.
|
||||||
- **`apply_bw_report_dict_to_event` helper** in `minimateplus.event_file_io`. Mirror of `apply_report_to_event` for the projected sidecar dict shape — used by the backfill path, which has the preserved `bw_report` block but not the original `.TXT` file. BW's reported peaks (and `sample_rate` / `record_time`) now win over codec output during `--force` backfill, matching ingest-path behavior.
|
- **`apply_bw_report_dict_to_event` helper** in `minimateplus.event_file_io`. Mirror of `apply_report_to_event` for the projected sidecar dict shape — used by the backfill path, which has the preserved `bw_report` block but not the original `.TXT` file. BW's reported peaks (and `sample_rate` / `record_time`) now win over codec output during `--force` backfill, matching ingest-path behavior.
|
||||||
- **`scripts/check_bw_report_preservation.py`** — two-step snapshot/diff tool to verify that `backfill_sidecars.py` doesn't wipe the `bw_report` block from existing sidecars. Classifies every sidecar as PRESERVED / CHANGED / WIPED / STILL_MISSING / NEW / ADDED / REMOVED. Exit code 1 if any WIPED or CHANGED entries are found, so it can gate a CI step or deploy script.
|
- **`scripts/check_bw_report_preservation.py`** — two-step snapshot/diff tool to verify that `backfill_sidecars.py` doesn't wipe the `bw_report` block from existing sidecars. Classifies every sidecar as PRESERVED / CHANGED / WIPED / STILL_MISSING / NEW / ADDED / REMOVED. Exit code 1 if any WIPED or CHANGED entries are found, so it can gate a CI step or deploy script.
|
||||||
|
|||||||
+310
-29
@@ -499,6 +499,20 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
table.db-table thead th[data-sort]:hover {
|
||||||
|
background: var(--border2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
table.db-table thead th .sort-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
color: var(--accent, #58a6ff);
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
table.db-table tbody tr { border-bottom: 1px solid var(--border2); }
|
table.db-table tbody tr { border-bottom: 1px solid var(--border2); }
|
||||||
table.db-table tbody tr:last-child { border-bottom: none; }
|
table.db-table tbody tr:last-child { border-bottom: none; }
|
||||||
@@ -758,7 +772,9 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
#section-db { display: none; }
|
/* Default to Database view on page load — most users are here to
|
||||||
|
browse stored events, not connect to a live unit. */
|
||||||
|
#section-live { display: none; }
|
||||||
|
|
||||||
/* ── Live connect bar (host/port/connect, live section only) ── */
|
/* ── Live connect bar (host/port/connect, live section only) ── */
|
||||||
#live-connect-bar {
|
#live-connect-bar {
|
||||||
@@ -792,8 +808,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hdr-sep"></div>
|
<div class="hdr-sep"></div>
|
||||||
<div class="section-switcher">
|
<div class="section-switcher">
|
||||||
<button class="section-btn active" onclick="switchSection('live')">Live Device</button>
|
<button class="section-btn" onclick="switchSection('live')">Live Device</button>
|
||||||
<button class="section-btn" onclick="switchSection('db')">Database</button>
|
<button class="section-btn active" onclick="switchSection('db')">Database</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hdr-sep"></div>
|
<div class="hdr-sep"></div>
|
||||||
<label class="force-toggle" id="force-toggle"
|
<label class="force-toggle" id="force-toggle"
|
||||||
@@ -1224,18 +1240,18 @@
|
|||||||
<div class="db-table-wrap" id="hist-table-wrap" style="display:none">
|
<div class="db-table-wrap" id="hist-table-wrap" style="display:none">
|
||||||
<table class="db-table" id="hist-table">
|
<table class="db-table" id="hist-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr id="hist-header-row">
|
||||||
<th>Timestamp</th>
|
<th data-sort="timestamp">Timestamp <span class="sort-arrow"></span></th>
|
||||||
<th>Serial</th>
|
<th data-sort="serial">Serial <span class="sort-arrow"></span></th>
|
||||||
<th>Tran (in/s)</th>
|
<th data-sort="tran_ppv">Tran (in/s) <span class="sort-arrow"></span></th>
|
||||||
<th>Vert (in/s)</th>
|
<th data-sort="vert_ppv">Vert (in/s) <span class="sort-arrow"></span></th>
|
||||||
<th>Long (in/s)</th>
|
<th data-sort="long_ppv">Long (in/s) <span class="sort-arrow"></span></th>
|
||||||
<th>PVS (in/s)</th>
|
<th data-sort="peak_vector_sum">PVS (in/s) <span class="sort-arrow"></span></th>
|
||||||
<th>Mic (dBL)</th>
|
<th data-sort="mic_ppv">Mic (dBL) <span class="sort-arrow"></span></th>
|
||||||
<th>Project</th>
|
<th data-sort="project">Project <span class="sort-arrow"></span></th>
|
||||||
<th>Client</th>
|
<th data-sort="client">Client <span class="sort-arrow"></span></th>
|
||||||
<th>Type</th>
|
<th data-sort="record_type">Type <span class="sort-arrow"></span></th>
|
||||||
<th>Key</th>
|
<th data-sort="waveform_key">Key <span class="sort-arrow"></span></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1388,7 +1404,9 @@ function deviceParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Section switching ─────────────────────────────────────────────────────────
|
// ── Section switching ─────────────────────────────────────────────────────────
|
||||||
let currentSection = 'live';
|
// Default to Database — most users land here to browse stored events.
|
||||||
|
// Live Device is opt-in (click the tab to talk to a unit).
|
||||||
|
let currentSection = 'db';
|
||||||
|
|
||||||
function switchSection(name) {
|
function switchSection(name) {
|
||||||
currentSection = name;
|
currentSection = name;
|
||||||
@@ -2333,6 +2351,12 @@ async function _fetchUnits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── History tab ────────────────────────────────────────────────────────────────
|
// ── History tab ────────────────────────────────────────────────────────────────
|
||||||
|
// Module-level state for the history table — preserved across re-sorts.
|
||||||
|
// We sort + re-render without re-fetching.
|
||||||
|
let _histEvents = [];
|
||||||
|
let _histSortKey = 'timestamp';
|
||||||
|
let _histSortDir = 'desc'; // 'asc' | 'desc'
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
histLoaded = true;
|
histLoaded = true;
|
||||||
const serial = document.getElementById('hist-serial-filter').value;
|
const serial = document.getElementById('hist-serial-filter').value;
|
||||||
@@ -2364,10 +2388,20 @@ async function loadHistory() {
|
|||||||
_populateSerialDropdown('monlog-serial-filter');
|
_populateSerialDropdown('monlog-serial-filter');
|
||||||
_populateSerialDropdown('sess-serial-filter');
|
_populateSerialDropdown('sess-serial-filter');
|
||||||
|
|
||||||
document.getElementById('hist-count').textContent = `${events.length} event${events.length !== 1 ? 's' : ''}`;
|
_histEvents = events;
|
||||||
|
renderHistTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render the history table from `_histEvents` using the current sort
|
||||||
|
// state. Pulled out of `loadHistory` so column-header clicks can re-sort
|
||||||
|
// in-memory without re-fetching from the server.
|
||||||
|
function renderHistTable() {
|
||||||
|
const events = _histEvents;
|
||||||
|
document.getElementById('hist-count').textContent =
|
||||||
|
`${events.length} event${events.length !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
const tbody = document.getElementById('hist-tbody');
|
const tbody = document.getElementById('hist-tbody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
document.getElementById('hist-empty').style.display = 'block';
|
document.getElementById('hist-empty').style.display = 'block';
|
||||||
document.getElementById('hist-table-wrap').style.display = 'none';
|
document.getElementById('hist-table-wrap').style.display = 'none';
|
||||||
@@ -2376,11 +2410,31 @@ async function loadHistory() {
|
|||||||
document.getElementById('hist-empty').style.display = 'none';
|
document.getElementById('hist-empty').style.display = 'none';
|
||||||
document.getElementById('hist-table-wrap').style.display = 'block';
|
document.getElementById('hist-table-wrap').style.display = 'block';
|
||||||
|
|
||||||
for (const ev of events) {
|
// Sort in-place by current key + direction. Nulls sink to the bottom
|
||||||
|
// regardless of direction.
|
||||||
|
const k = _histSortKey;
|
||||||
|
const dir = _histSortDir === 'asc' ? 1 : -1;
|
||||||
|
const sorted = [...events].sort((a, b) => {
|
||||||
|
const av = a[k], bv = b[k];
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update arrow indicators in the headers
|
||||||
|
document.querySelectorAll('#hist-header-row th[data-sort]').forEach(th => {
|
||||||
|
const arrow = th.querySelector('.sort-arrow');
|
||||||
|
if (!arrow) return;
|
||||||
|
arrow.textContent = th.dataset.sort === k ? (_histSortDir === 'asc' ? '↑' : '↓') : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ev of sorted) {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const pvs = ev.peak_vector_sum;
|
const pvs = ev.peak_vector_sum;
|
||||||
tr.classList.add('clickable');
|
tr.classList.add('clickable');
|
||||||
tr.title = 'Click to review (open sidecar editor)';
|
tr.title = 'Click to view waveform + sidecar';
|
||||||
tr.dataset.eventId = ev.id;
|
tr.dataset.eventId = ev.id;
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${_fmtTs(ev.timestamp)}</td>
|
<td>${_fmtTs(ev.timestamp)}</td>
|
||||||
@@ -2408,6 +2462,28 @@ async function loadHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click a column header → toggle sort. Click another → set sort to that column.
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const headerRow = document.getElementById('hist-header-row');
|
||||||
|
if (!headerRow) return;
|
||||||
|
headerRow.querySelectorAll('th[data-sort]').forEach(th => {
|
||||||
|
th.style.cursor = 'pointer';
|
||||||
|
th.style.userSelect = 'none';
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
const k = th.dataset.sort;
|
||||||
|
if (_histSortKey === k) {
|
||||||
|
_histSortDir = _histSortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
_histSortKey = k;
|
||||||
|
// Default direction: 'desc' for numbers + timestamps (biggest/newest first),
|
||||||
|
// 'asc' for text columns (alphabetical).
|
||||||
|
_histSortDir = ['serial','project','client','record_type','waveform_key'].includes(k) ? 'asc' : 'desc';
|
||||||
|
}
|
||||||
|
renderHistTable();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Sidecar review modal ───────────────────────────────────────────────────────
|
// ── Sidecar review modal ───────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Opens on row click in the History table. Loads the .sfm.json sidecar
|
// Opens on row click in the History table. Loads the .sfm.json sidecar
|
||||||
@@ -2430,23 +2506,214 @@ async function openSidecarModal(eventId) {
|
|||||||
document.getElementById('sc-edit-ft').checked = false;
|
document.getElementById('sc-edit-ft').checked = false;
|
||||||
document.getElementById('sc-edit-reviewer').value = '';
|
document.getElementById('sc-edit-reviewer').value = '';
|
||||||
document.getElementById('sc-edit-notes').value = '';
|
document.getElementById('sc-edit-notes').value = '';
|
||||||
|
// Reset waveform area
|
||||||
|
document.getElementById('sc-waveform-status').textContent = 'Loading waveform…';
|
||||||
|
document.getElementById('sc-waveform-charts').innerHTML = '';
|
||||||
|
_destroyScCharts();
|
||||||
|
|
||||||
try {
|
// Sidecar + waveform fetched in parallel — neither blocks the other.
|
||||||
const r = await fetch(`${api()}/db/events/${eventId}/sidecar`);
|
const sidecarP = fetch(`${api()}/db/events/${eventId}/sidecar`)
|
||||||
if (!r.ok) {
|
.then(async r => {
|
||||||
const e = await r.json().catch(() => ({}));
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||||
throw new Error(e.detail || r.statusText);
|
return r.json();
|
||||||
}
|
});
|
||||||
const data = await r.json();
|
const waveformP = fetch(`${api()}/db/events/${eventId}/waveform.json`)
|
||||||
|
.then(async r => {
|
||||||
|
if (r.status === 404) return null; // no waveform available — render empty state
|
||||||
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidecar usually loads first (smaller payload). Each one renders
|
||||||
|
// independently so the modal becomes useful as soon as either lands.
|
||||||
|
sidecarP.then(data => {
|
||||||
_scCurrentSidecar = data;
|
_scCurrentSidecar = data;
|
||||||
_renderSidecar(data);
|
_renderSidecar(data);
|
||||||
document.getElementById('sc-status').textContent = '';
|
document.getElementById('sc-status').textContent = '';
|
||||||
} catch (e) {
|
}).catch(e => {
|
||||||
document.getElementById('sc-status').className = 'sc-status error';
|
document.getElementById('sc-status').className = 'sc-status error';
|
||||||
document.getElementById('sc-status').textContent = `Load failed: ${e.message}`;
|
document.getElementById('sc-status').textContent = `Sidecar load failed: ${e.message}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
waveformP.then(data => {
|
||||||
|
if (!data) {
|
||||||
|
document.getElementById('sc-waveform-status').textContent = 'No waveform data for this event.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_renderScWaveform(data);
|
||||||
|
}).catch(e => {
|
||||||
|
document.getElementById('sc-waveform-status').textContent = `Waveform load failed: ${e.message}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidecar-modal waveform plot ──────────────────────────────────────────────
|
||||||
|
// Renders the 4-channel decoded waveform fetched from
|
||||||
|
// /db/events/{id}/waveform.json — MicL on top, Tran on bottom (matches
|
||||||
|
// Instantel BW Event Report layout). Uses Chart.js (loaded at the top of
|
||||||
|
// the page for the live-device viewer).
|
||||||
|
const _SC_CHANNEL_COLORS = {
|
||||||
|
MicL: '#e066ff',
|
||||||
|
Long: '#3a80ff',
|
||||||
|
Vert: '#3fb950',
|
||||||
|
Tran: '#f85149',
|
||||||
|
};
|
||||||
|
const _SC_CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
|
||||||
|
let _scCharts = {};
|
||||||
|
|
||||||
|
function _destroyScCharts() {
|
||||||
|
Object.values(_scCharts).forEach(c => { try { c.destroy(); } catch {} });
|
||||||
|
_scCharts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderScWaveform(data) {
|
||||||
|
document.getElementById('sc-waveform-status').textContent = '';
|
||||||
|
const chartsDiv = document.getElementById('sc-waveform-charts');
|
||||||
|
chartsDiv.innerHTML = '';
|
||||||
|
_destroyScCharts();
|
||||||
|
|
||||||
|
const channels = data.channels || {};
|
||||||
|
const timeAxis = data.time_axis || null;
|
||||||
|
const triggerMs = data.trigger_ms ?? 0;
|
||||||
|
|
||||||
|
// Which channels have data — determines which one renders the shared bottom axis.
|
||||||
|
const withData = _SC_CHANNEL_ORDER.filter(ch =>
|
||||||
|
channels[ch] && (channels[ch].values || []).length > 0
|
||||||
|
);
|
||||||
|
const lastCh = withData[withData.length - 1];
|
||||||
|
|
||||||
|
for (const ch of _SC_CHANNEL_ORDER) {
|
||||||
|
const chData = channels[ch];
|
||||||
|
if (!chData) continue;
|
||||||
|
const values = chData.values || [];
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.style.cssText = 'background:var(--surface);border:1px solid var(--border2);border-radius:6px;padding:6px 30px 4px 10px';
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.style.cssText = `font-size:10px;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;margin-bottom:2px;color:${_SC_CHANNEL_COLORS[ch]};display:flex;justify-content:space-between`;
|
||||||
|
const peakStr = chData.peak != null
|
||||||
|
? `peak ${(typeof chData.peak === 'number' ? chData.peak.toExponential(3) : chData.peak)} ${chData.unit || ''}`
|
||||||
|
: '';
|
||||||
|
lbl.innerHTML = `<span>${ch}</span><span style="color:var(--text-dim);font-weight:normal">${peakStr}</span>`;
|
||||||
|
wrap.appendChild(lbl);
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
const e = document.createElement('div');
|
||||||
|
e.style.cssText = 'height:80px;display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:11px';
|
||||||
|
e.textContent = 'no samples decoded';
|
||||||
|
wrap.appendChild(e);
|
||||||
|
chartsDiv.appendChild(wrap);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasWrap = document.createElement('div');
|
||||||
|
canvasWrap.style.cssText = 'position:relative;height:100px';
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvasWrap.appendChild(canvas);
|
||||||
|
wrap.appendChild(canvasWrap);
|
||||||
|
chartsDiv.appendChild(wrap);
|
||||||
|
|
||||||
|
// Build time axis. Prefer server-provided time_axis; else derive from sample_rate.
|
||||||
|
let times;
|
||||||
|
if (timeAxis && timeAxis.length === values.length) {
|
||||||
|
times = timeAxis;
|
||||||
|
} else {
|
||||||
|
const sr = data.sample_rate || 1024;
|
||||||
|
times = values.map((_, i) => (i / sr * 1000 - triggerMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downsample for rendering when very long.
|
||||||
|
const MAX = 3000;
|
||||||
|
let rT = times, rV = values;
|
||||||
|
if (values.length > MAX) {
|
||||||
|
const step = Math.ceil(values.length / MAX);
|
||||||
|
rT = times.filter((_, i) => i % step === 0);
|
||||||
|
rV = values.filter((_, i) => i % step === 0);
|
||||||
|
}
|
||||||
|
const showX = (ch === lastCh);
|
||||||
|
|
||||||
|
_scCharts[ch] = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: rT.map(t => (typeof t === 'number' ? t.toFixed(2) : t)),
|
||||||
|
datasets: [{
|
||||||
|
data: rV,
|
||||||
|
borderColor: _SC_CHANNEL_COLORS[ch],
|
||||||
|
borderWidth: 1,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: false, responsive: true, maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index', intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: items => `t = ${items[0].label} ms`,
|
||||||
|
label: item => `${ch}: ${item.raw} ${chData.unit || ''}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'category', display: showX,
|
||||||
|
ticks: { color: '#484f58', maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => rT[i] + ' ms' },
|
||||||
|
grid: { color: '#21262d', drawTicks: showX },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: '#484f58', maxTicksLimit: 4 },
|
||||||
|
grid: { color: '#21262d' },
|
||||||
|
title: { display: true, text: chData.unit || '', color: '#484f58', font: { size: 9 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [{
|
||||||
|
id: 'overlays',
|
||||||
|
afterDraw(chart) {
|
||||||
|
const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y;
|
||||||
|
// Dashed trigger line at t=0
|
||||||
|
const zi = rT.findIndex(t => parseFloat(t) >= 0);
|
||||||
|
if (zi >= 0) {
|
||||||
|
const px = x.getPixelForValue(zi);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
|
||||||
|
ctx.strokeStyle = 'rgba(248,81,73,0.8)'; ctx.lineWidth = 1.2;
|
||||||
|
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
|
||||||
|
// Triangle markers above and below the chart
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#f85149';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1);
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1);
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
// Zero baseline + label
|
||||||
|
const zy = y.getPixelForValue(0);
|
||||||
|
if (zy >= y.top && zy <= y.bottom) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = '#30363d'; ctx.lineWidth = 0.8;
|
||||||
|
ctx.setLineDash([2, 2]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#c9d1d9'; ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('0.0', x.right + 6, zy);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure charts get cleaned up when the modal closes.
|
||||||
|
function _scCleanupOnClose() { _destroyScCharts(); }
|
||||||
|
|
||||||
function _renderSidecar(data) {
|
function _renderSidecar(data) {
|
||||||
const ev = data.event || {};
|
const ev = data.event || {};
|
||||||
const pv = data.peak_values || {};
|
const pv = data.peak_values || {};
|
||||||
@@ -2512,6 +2779,7 @@ function closeSidecarModal() {
|
|||||||
document.getElementById('sc-overlay').classList.remove('visible');
|
document.getElementById('sc-overlay').classList.remove('visible');
|
||||||
_scCurrentEventId = null;
|
_scCurrentEventId = null;
|
||||||
_scCurrentSidecar = null;
|
_scCurrentSidecar = null;
|
||||||
|
_destroyScCharts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSidecarOverlayClick(e) {
|
function onSidecarOverlayClick(e) {
|
||||||
@@ -2722,6 +2990,13 @@ document.addEventListener('keydown', e => {
|
|||||||
// hit localhost:8200, 10.0.0.44:8200, or anything else.
|
// hit localhost:8200, 10.0.0.44:8200, or anything else.
|
||||||
document.getElementById('api-base').value = window.location.origin;
|
document.getElementById('api-base').value = window.location.origin;
|
||||||
|
|
||||||
|
// We default to Database view → trigger initial history + units load
|
||||||
|
// (switchSection handles this when clicked, but we never click on first paint).
|
||||||
|
if (currentSection === 'db') {
|
||||||
|
if (!histLoaded) loadHistory();
|
||||||
|
if (!unitsLoaded) loadUnits();
|
||||||
|
}
|
||||||
|
|
||||||
// Press Enter in any live connect field to connect
|
// Press Enter in any live connect field to connect
|
||||||
['dev-host','dev-port'].forEach(id => {
|
['dev-host','dev-port'].forEach(id => {
|
||||||
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
|
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
|
||||||
@@ -2738,6 +3013,12 @@ document.getElementById('api-base').value = window.location.origin;
|
|||||||
<button class="sc-close" onclick="closeSidecarModal()">×</button>
|
<button class="sc-close" onclick="closeSidecarModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sc-body">
|
<div class="sc-body">
|
||||||
|
<!-- Waveform plot — 4 channels stacked (MicL, Long, Vert, Tran) — -->
|
||||||
|
<div class="sc-section" id="sc-section-waveform">
|
||||||
|
<h4>Waveform</h4>
|
||||||
|
<div id="sc-waveform-status" style="color:var(--text-dim);font-size:11px;margin-bottom:6px">Loading…</div>
|
||||||
|
<div id="sc-waveform-charts" style="display:flex;flex-direction:column;gap:6px"></div>
|
||||||
|
</div>
|
||||||
<div class="sc-section">
|
<div class="sc-section">
|
||||||
<h4>Event</h4>
|
<h4>Event</h4>
|
||||||
<dl class="sc-grid">
|
<dl class="sc-grid">
|
||||||
|
|||||||
Reference in New Issue
Block a user