v0.20.0 -- Full s3 event parse and PDF creation. #28
+2
-1
@@ -8,7 +8,8 @@ All notable changes to seismo-relay are documented here.
|
||||
|
||||
### 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.
|
||||
- **`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.
|
||||
|
||||
+310
-29
@@ -499,6 +499,20 @@
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
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:last-child { border-bottom: none; }
|
||||
@@ -758,7 +772,9 @@
|
||||
overflow: hidden;
|
||||
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 {
|
||||
@@ -792,8 +808,8 @@
|
||||
</div>
|
||||
<div class="hdr-sep"></div>
|
||||
<div class="section-switcher">
|
||||
<button class="section-btn active" onclick="switchSection('live')">Live Device</button>
|
||||
<button class="section-btn" onclick="switchSection('db')">Database</button>
|
||||
<button class="section-btn" onclick="switchSection('live')">Live Device</button>
|
||||
<button class="section-btn active" onclick="switchSection('db')">Database</button>
|
||||
</div>
|
||||
<div class="hdr-sep"></div>
|
||||
<label class="force-toggle" id="force-toggle"
|
||||
@@ -1224,18 +1240,18 @@
|
||||
<div class="db-table-wrap" id="hist-table-wrap" style="display:none">
|
||||
<table class="db-table" id="hist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Serial</th>
|
||||
<th>Tran (in/s)</th>
|
||||
<th>Vert (in/s)</th>
|
||||
<th>Long (in/s)</th>
|
||||
<th>PVS (in/s)</th>
|
||||
<th>Mic (dBL)</th>
|
||||
<th>Project</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Key</th>
|
||||
<tr id="hist-header-row">
|
||||
<th data-sort="timestamp">Timestamp <span class="sort-arrow"></span></th>
|
||||
<th data-sort="serial">Serial <span class="sort-arrow"></span></th>
|
||||
<th data-sort="tran_ppv">Tran (in/s) <span class="sort-arrow"></span></th>
|
||||
<th data-sort="vert_ppv">Vert (in/s) <span class="sort-arrow"></span></th>
|
||||
<th data-sort="long_ppv">Long (in/s) <span class="sort-arrow"></span></th>
|
||||
<th data-sort="peak_vector_sum">PVS (in/s) <span class="sort-arrow"></span></th>
|
||||
<th data-sort="mic_ppv">Mic (dBL) <span class="sort-arrow"></span></th>
|
||||
<th data-sort="project">Project <span class="sort-arrow"></span></th>
|
||||
<th data-sort="client">Client <span class="sort-arrow"></span></th>
|
||||
<th data-sort="record_type">Type <span class="sort-arrow"></span></th>
|
||||
<th data-sort="waveform_key">Key <span class="sort-arrow"></span></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1388,7 +1404,9 @@ function deviceParams() {
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
currentSection = name;
|
||||
@@ -2333,6 +2351,12 @@ async function _fetchUnits() {
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
histLoaded = true;
|
||||
const serial = document.getElementById('hist-serial-filter').value;
|
||||
@@ -2364,10 +2388,20 @@ async function loadHistory() {
|
||||
_populateSerialDropdown('monlog-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');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (events.length === 0) {
|
||||
document.getElementById('hist-empty').style.display = 'block';
|
||||
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-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 pvs = ev.peak_vector_sum;
|
||||
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.innerHTML = `
|
||||
<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 ───────────────────────────────────────────────────────
|
||||
//
|
||||
// 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-reviewer').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 {
|
||||
const r = await fetch(`${api()}/db/events/${eventId}/sidecar`);
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
const data = await r.json();
|
||||
// Sidecar + waveform fetched in parallel — neither blocks the other.
|
||||
const sidecarP = fetch(`${api()}/db/events/${eventId}/sidecar`)
|
||||
.then(async r => {
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||
return 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;
|
||||
_renderSidecar(data);
|
||||
document.getElementById('sc-status').textContent = '';
|
||||
} catch (e) {
|
||||
}).catch(e => {
|
||||
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) {
|
||||
const ev = data.event || {};
|
||||
const pv = data.peak_values || {};
|
||||
@@ -2512,6 +2779,7 @@ function closeSidecarModal() {
|
||||
document.getElementById('sc-overlay').classList.remove('visible');
|
||||
_scCurrentEventId = null;
|
||||
_scCurrentSidecar = null;
|
||||
_destroyScCharts();
|
||||
}
|
||||
|
||||
function onSidecarOverlayClick(e) {
|
||||
@@ -2722,6 +2990,13 @@ document.addEventListener('keydown', e => {
|
||||
// hit localhost:8200, 10.0.0.44:8200, or anything else.
|
||||
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
|
||||
['dev-host','dev-port'].forEach(id => {
|
||||
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>
|
||||
</div>
|
||||
<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">
|
||||
<h4>Event</h4>
|
||||
<dl class="sc-grid">
|
||||
|
||||
Reference in New Issue
Block a user