0da88ec6aa
The server now re-computes rectime_seconds using the actual sample rate from the compliance config (overriding the default 1024 in the client), so if the device runs at 2048 or 4096 sps it's still correct. Viewer — The rectime display now shows Xs (stored) / Ys (cfg) so you can compare the STRT-derived duration against the compliance config's record_time setting side-by-side. I also clamped the y-axis to ±(0C peak × 1.4) so near-saturation decode artifacts don't squash the real blast signal into a flat line.
680 lines
22 KiB
HTML
680 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>SFM Waveform Viewer</title>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
background: #0d1117;
|
||
color: #c9d1d9;
|
||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||
font-size: 13px;
|
||
}
|
||
|
||
header {
|
||
background: #161b22;
|
||
border-bottom: 1px solid #30363d;
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
header h1 {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #f0f6fc;
|
||
white-space: nowrap;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.conn-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
label { color: #8b949e; font-size: 12px; }
|
||
|
||
input[type="text"], input[type="number"] {
|
||
background: #0d1117;
|
||
border: 1px solid #30363d;
|
||
border-radius: 6px;
|
||
color: #c9d1d9;
|
||
padding: 5px 8px;
|
||
font-size: 13px;
|
||
width: 100px;
|
||
}
|
||
input[type="number"] { width: 70px; }
|
||
input:focus { outline: none; border-color: #388bfd; }
|
||
|
||
button {
|
||
background: #1f6feb;
|
||
border: none;
|
||
border-radius: 6px;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
padding: 5px 14px;
|
||
transition: background 0.15s;
|
||
}
|
||
button:hover { background: #388bfd; }
|
||
button:active { background: #1158c7; }
|
||
button:disabled { background: #21262d; color: #484f58; cursor: not-allowed; }
|
||
|
||
#status-bar {
|
||
background: #161b22;
|
||
border-bottom: 1px solid #21262d;
|
||
padding: 5px 20px;
|
||
font-size: 12px;
|
||
color: #8b949e;
|
||
min-height: 26px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
#status-bar.error { color: #f85149; }
|
||
#status-bar.ok { color: #3fb950; }
|
||
#status-bar.loading { color: #d29922; }
|
||
|
||
.meta-pill {
|
||
background: #21262d;
|
||
border-radius: 4px;
|
||
padding: 2px 8px;
|
||
color: #c9d1d9;
|
||
font-family: monospace;
|
||
}
|
||
|
||
#charts {
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.chart-wrap {
|
||
background: #161b22;
|
||
border: 1px solid #21262d;
|
||
border-radius: 8px;
|
||
padding: 10px 12px 8px;
|
||
}
|
||
|
||
.chart-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.chart-canvas-wrap { position: relative; height: 130px; }
|
||
|
||
#empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 60vh;
|
||
color: #484f58;
|
||
gap: 8px;
|
||
}
|
||
#empty-state svg { opacity: 0.3; }
|
||
#empty-state p { font-size: 14px; }
|
||
|
||
.ch-tran { color: #58a6ff; }
|
||
.ch-vert { color: #3fb950; }
|
||
.ch-long { color: #d29922; }
|
||
.ch-mic { color: #bc8cff; }
|
||
|
||
#unit-bar {
|
||
background: #0d1117;
|
||
border-bottom: 1px solid #21262d;
|
||
padding: 8px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.unit-field { display: flex; flex-direction: column; gap: 1px; }
|
||
.unit-field .uf-label { color: #484f58; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.unit-field .uf-value { color: #c9d1d9; font-family: monospace; font-size: 13px; }
|
||
.unit-field .uf-value.highlight { color: #58a6ff; font-weight: 600; }
|
||
|
||
.event-chips {
|
||
display: flex;
|
||
gap: 5px;
|
||
flex-wrap: wrap;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.event-chip {
|
||
background: #21262d;
|
||
border: 1px solid #30363d;
|
||
border-radius: 5px;
|
||
color: #8b949e;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
padding: 3px 10px;
|
||
transition: all 0.12s;
|
||
}
|
||
.event-chip:hover { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
||
.event-chip.active { background: #1f6feb; border-color: #388bfd; color: #fff; font-weight: 600; }
|
||
|
||
#connect-btn {
|
||
background: #238636;
|
||
margin-left: auto;
|
||
}
|
||
#connect-btn:hover { background: #2ea043; }
|
||
#connect-btn:disabled { background: #21262d; color: #484f58; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1>SFM Waveform Viewer</h1>
|
||
<div class="conn-group">
|
||
<label>API</label>
|
||
<input type="text" id="api-base" style="width:180px" />
|
||
</div>
|
||
<div class="conn-group">
|
||
<label>Device host</label>
|
||
<input type="text" id="dev-host" value="" placeholder="e.g. 10.0.0.5" />
|
||
<label>TCP port</label>
|
||
<input type="number" id="dev-tcp-port" value="9034" />
|
||
</div>
|
||
<button id="connect-btn" onclick="connectUnit()">Connect</button>
|
||
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
|
||
<label style="display:flex;align-items:center;gap:4px;color:#8b949e;font-size:12px;cursor:pointer">
|
||
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb" />
|
||
Force reload
|
||
</label>
|
||
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
|
||
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
|
||
</header>
|
||
|
||
<!-- Unit info bar — hidden until connected -->
|
||
<div id="unit-bar" style="display:none">
|
||
<div class="unit-field">
|
||
<span class="uf-label">Serial</span>
|
||
<span class="uf-value" id="u-serial">—</span>
|
||
</div>
|
||
<div class="unit-field">
|
||
<span class="uf-label">Firmware</span>
|
||
<span class="uf-value" id="u-fw">—</span>
|
||
</div>
|
||
<div class="unit-field">
|
||
<span class="uf-label">Sample rate</span>
|
||
<span class="uf-value" id="u-sr">—</span>
|
||
</div>
|
||
<div class="unit-field">
|
||
<span class="uf-label">Events</span>
|
||
<span class="uf-value highlight" id="u-count">—</span>
|
||
</div>
|
||
<div class="event-chips" id="event-chips"></div>
|
||
</div>
|
||
|
||
<div id="status-bar">Ready — enter device host and click Connect.</div>
|
||
|
||
<div id="empty-state">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||
</svg>
|
||
<p>No waveform loaded</p>
|
||
</div>
|
||
|
||
<div id="charts" style="display:none"></div>
|
||
|
||
<script>
|
||
const CHANNEL_COLORS = {
|
||
Tran: '#58a6ff',
|
||
Vert: '#3fb950',
|
||
Long: '#d29922',
|
||
Mic: '#bc8cff',
|
||
};
|
||
|
||
let charts = {};
|
||
let lastData = null;
|
||
let unitInfo = null;
|
||
let geoRange = 10.0; // in/s full-scale for geo channels; updated on connect
|
||
let eventList = []; // populated from /device/events after connect
|
||
let currentEventIndex = 0;
|
||
|
||
// ── DB mode: opened via ?db_id=<uuid>&api_base=<url> from History tab ────────
|
||
const _urlParams = new URLSearchParams(window.location.search);
|
||
const _dbId = _urlParams.get('db_id');
|
||
const _dbApiBase = (_urlParams.get('api_base') || '').replace(/\/$/, '');
|
||
|
||
async function _loadFromDb() {
|
||
const apiBase = _dbApiBase || document.getElementById('api-base').value.replace(/\/$/, '');
|
||
setStatus('Loading waveform from database…', 'loading');
|
||
document.getElementById('unit-bar').style.display = 'none';
|
||
// Hide live-device controls — not relevant in DB mode
|
||
document.querySelector('header .conn-group').style.display = 'none';
|
||
|
||
const url = `${apiBase}/db/events/${encodeURIComponent(_dbId)}/waveform`;
|
||
let data;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||
throw new Error(err.detail || resp.statusText);
|
||
}
|
||
data = await resp.json();
|
||
} catch (e) {
|
||
setStatus(`Error: ${e.message}`, 'error');
|
||
return;
|
||
}
|
||
lastData = data;
|
||
renderWaveform(data);
|
||
}
|
||
|
||
// Auto-load when opened with db_id param
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
if (_dbId) {
|
||
// Pre-fill api-base if provided
|
||
if (_dbApiBase) {
|
||
document.getElementById('api-base').value = _dbApiBase;
|
||
}
|
||
_loadFromDb();
|
||
}
|
||
});
|
||
|
||
function setStatus(msg, cls = '') {
|
||
const bar = document.getElementById('status-bar');
|
||
bar.textContent = msg;
|
||
bar.className = cls;
|
||
}
|
||
|
||
function appendMeta(label, value) {
|
||
const bar = document.getElementById('status-bar');
|
||
const pill = document.createElement('span');
|
||
pill.className = 'meta-pill';
|
||
pill.textContent = `${label}: ${value}`;
|
||
bar.appendChild(pill);
|
||
}
|
||
|
||
async function connectUnit() {
|
||
const apiBase = document.getElementById('api-base').value.replace(/\/$/, '');
|
||
const devHost = document.getElementById('dev-host').value.trim();
|
||
const tcpPort = document.getElementById('dev-tcp-port').value;
|
||
|
||
if (!devHost) { setStatus('Enter a device host first.', 'error'); return; }
|
||
|
||
const btn = document.getElementById('connect-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Connecting…';
|
||
setStatus('Connecting to unit…', 'loading');
|
||
|
||
const url = `${apiBase}/device/info?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||
throw new Error(err.detail || resp.statusText);
|
||
}
|
||
unitInfo = await resp.json();
|
||
geoRange = unitInfo.compliance_config?.max_range_geo ?? 10.0;
|
||
} catch (e) {
|
||
setStatus(`Error: ${e.message}`, 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Connect';
|
||
return;
|
||
}
|
||
|
||
// Populate unit bar from /device/info
|
||
document.getElementById('u-serial').textContent = unitInfo.serial || '—';
|
||
document.getElementById('u-fw').textContent = unitInfo.firmware_version || '—';
|
||
const sr = unitInfo.compliance_config?.sample_rate;
|
||
document.getElementById('u-sr').textContent = sr ? `${sr} sps` : '—';
|
||
|
||
// Fetch real event list from /device/events — SUB 08 count is unreliable
|
||
setStatus('Fetching event list…', 'loading');
|
||
const eventsUrl = `${apiBase}/device/events?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
||
try {
|
||
const evResp = await fetch(eventsUrl);
|
||
if (!evResp.ok) {
|
||
const err = await evResp.json().catch(() => ({ detail: evResp.statusText }));
|
||
throw new Error(err.detail || evResp.statusText);
|
||
}
|
||
const evData = await evResp.json();
|
||
eventList = evData.events || [];
|
||
} catch (e) {
|
||
setStatus(`Error fetching events: ${e.message}`, 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Reconnect';
|
||
return;
|
||
}
|
||
|
||
const count = eventList.length;
|
||
document.getElementById('u-count').textContent = count;
|
||
|
||
// Build event chips with timestamps
|
||
const chipsEl = document.getElementById('event-chips');
|
||
chipsEl.innerHTML = '';
|
||
eventList.forEach((ev, i) => {
|
||
const chip = document.createElement('button');
|
||
chip.className = 'event-chip' + (i === 0 ? ' active' : '');
|
||
const label = ev.timestamp?.display ?? `Event ${ev.index}`;
|
||
chip.textContent = label;
|
||
chip.title = ev.record_type || '';
|
||
chip.onclick = () => selectEvent(i);
|
||
chipsEl.appendChild(chip);
|
||
});
|
||
|
||
document.getElementById('unit-bar').style.display = 'flex';
|
||
document.getElementById('load-btn').disabled = count === 0;
|
||
document.getElementById('prev-btn').disabled = true;
|
||
document.getElementById('next-btn').disabled = count <= 1;
|
||
|
||
btn.disabled = false;
|
||
btn.textContent = 'Reconnect';
|
||
|
||
if (count === 0) {
|
||
setStatus('Connected — no events stored on device.', 'ok');
|
||
} else {
|
||
setStatus(`Connected — ${count} event${count !== 1 ? 's' : ''} stored. Select an event or click Load Waveform.`, 'ok');
|
||
}
|
||
}
|
||
|
||
function selectEvent(idx) {
|
||
currentEventIndex = idx;
|
||
// Update chip highlight
|
||
document.querySelectorAll('.event-chip').forEach((c, i) => {
|
||
c.classList.toggle('active', i === idx);
|
||
});
|
||
document.getElementById('prev-btn').disabled = idx <= 0;
|
||
document.getElementById('next-btn').disabled = idx >= eventList.length - 1;
|
||
loadWaveform();
|
||
}
|
||
|
||
async function loadWaveform() {
|
||
const apiBase = document.getElementById('api-base').value.replace(/\/$/, '');
|
||
const devHost = document.getElementById('dev-host').value.trim();
|
||
const tcpPort = document.getElementById('dev-tcp-port').value;
|
||
const evIndex = currentEventIndex;
|
||
|
||
if (!devHost) { setStatus('Enter a device host first.', 'error'); return; }
|
||
|
||
const btn = document.getElementById('load-btn');
|
||
btn.disabled = true;
|
||
setStatus('Fetching waveform…', 'loading');
|
||
|
||
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
|
||
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}${force}`;
|
||
|
||
let data;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||
throw new Error(err.detail || resp.statusText);
|
||
}
|
||
data = await resp.json();
|
||
} catch (e) {
|
||
setStatus(`Error: ${e.message}`, 'error');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
lastData = data;
|
||
renderWaveform(data);
|
||
btn.disabled = false;
|
||
}
|
||
|
||
function stepEvent(delta) {
|
||
const count = eventList.length;
|
||
const next = Math.max(0, Math.min(count - 1, currentEventIndex + delta));
|
||
selectEvent(next);
|
||
}
|
||
|
||
function renderWaveform(data) {
|
||
const sr = data.sample_rate || 1024;
|
||
const pretrig = data.pretrig_samples || 0;
|
||
const decoded = data.samples_decoded || 0;
|
||
const total = data.total_samples || decoded;
|
||
const channels = data.channels || {};
|
||
const recType = data.record_type || 'Unknown';
|
||
|
||
// Status bar
|
||
const bar = document.getElementById('status-bar');
|
||
bar.innerHTML = '';
|
||
bar.className = 'ok';
|
||
const ts = data.timestamp;
|
||
const tsDisplay = ts
|
||
? (typeof ts === 'string' ? ts : (ts.display ?? JSON.stringify(ts)))
|
||
: null;
|
||
if (tsDisplay) {
|
||
bar.textContent = `Event #${data.index} — ${tsDisplay} `;
|
||
} else {
|
||
bar.textContent = `Event #${data.index} `;
|
||
}
|
||
appendMeta('type', recType);
|
||
appendMeta('sr', `${sr} sps`);
|
||
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
|
||
appendMeta('pretrig', pretrig);
|
||
// rectime_seconds is computed from (total_samples - pretrig_samples) / sr in
|
||
// _decode_a5_waveform. Also show the compliance config record_time for reference.
|
||
const cfgRt = unitInfo?.compliance_config?.record_time;
|
||
const strtRt = data.rectime_seconds;
|
||
const rtStr = (strtRt !== null && strtRt !== undefined)
|
||
? `${strtRt}s (stored)` + (cfgRt !== null && cfgRt !== undefined ? ` / ${cfgRt}s (cfg)` : '')
|
||
: (cfgRt !== null && cfgRt !== undefined ? `${cfgRt}s (cfg)` : '?');
|
||
appendMeta('rectime', rtStr);
|
||
|
||
// No waveform data — show a clear reason instead of empty charts
|
||
if (decoded === 0) {
|
||
document.getElementById('empty-state').style.display = 'flex';
|
||
document.getElementById('empty-state').querySelector('p').textContent =
|
||
recType === 'Waveform'
|
||
? 'Waveform decode returned no samples — check server logs'
|
||
: `Record type "${recType}" — waveform decode not yet supported for this mode`;
|
||
document.getElementById('charts').style.display = 'none';
|
||
Object.values(charts).forEach(c => c.destroy());
|
||
charts = {};
|
||
return;
|
||
}
|
||
|
||
// Build time axis (ms)
|
||
const times = Array.from({ length: decoded }, (_, i) =>
|
||
((i - pretrig) / sr * 1000).toFixed(2)
|
||
);
|
||
|
||
// Show charts area
|
||
document.getElementById('empty-state').style.display = 'none';
|
||
const chartsDiv = document.getElementById('charts');
|
||
chartsDiv.style.display = 'flex';
|
||
chartsDiv.innerHTML = '';
|
||
|
||
// Destroy old Chart instances
|
||
Object.values(charts).forEach(c => c.destroy());
|
||
charts = {};
|
||
|
||
// Mic peak PSI from 0C waveform record — used to scale raw mic counts
|
||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
|
||
|
||
// 0C record peak values (device-computed, authoritative) per channel
|
||
const peakValues0C = {
|
||
Tran: data.peak_values?.tran_in_s ?? null,
|
||
Vert: data.peak_values?.vert_in_s ?? null,
|
||
Long: data.peak_values?.long_in_s ?? null,
|
||
};
|
||
|
||
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
||
const samples = channels[ch];
|
||
if (!samples || samples.length === 0) continue;
|
||
|
||
// Convert raw ADC counts to physical units
|
||
const isGeo = ch !== 'Mic';
|
||
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
|
||
|
||
if (isGeo) {
|
||
// Geo channels: counts × (range / 32767) → in/s
|
||
// Scale factor for the waveform shape (may need calibration per unit)
|
||
const scale = geoRange / 32767;
|
||
plotSamples = samples.map(c => c * scale);
|
||
|
||
// Use the device-computed 0C record peak for the label (authoritative).
|
||
// The raw-sample-computed peak can be inflated by frame-boundary artifacts.
|
||
const peak0C = peakValues0C[ch];
|
||
const peakIns = (peak0C !== null && peak0C !== undefined)
|
||
? peak0C
|
||
: Math.max(...plotSamples.map(Math.abs));
|
||
peakLabel = `${peakIns.toFixed(5)} in/s`;
|
||
yUnit = 'in/s';
|
||
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
|
||
tickFmt = v => v.toFixed(4);
|
||
} else {
|
||
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header
|
||
const peakCounts = Math.max(...samples.map(Math.abs));
|
||
const micScale = (micPeakPsi !== null && peakCounts > 0)
|
||
? Math.abs(micPeakPsi) / peakCounts
|
||
: 1.0;
|
||
plotSamples = samples.map(c => c * micScale);
|
||
const peakPsi = Math.max(...plotSamples.map(Math.abs));
|
||
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
|
||
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
|
||
yUnit = 'psi';
|
||
tooltipFmt = v => `${ch}: ${v.toExponential(3)} psi`;
|
||
tickFmt = v => v.toExponential(1);
|
||
}
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'chart-wrap';
|
||
|
||
const lbl = document.createElement('div');
|
||
lbl.className = `chart-label ch-${ch.toLowerCase()}`;
|
||
lbl.textContent = `${ch} — peak ${peakLabel}`;
|
||
wrap.appendChild(lbl);
|
||
|
||
const canvasWrap = document.createElement('div');
|
||
canvasWrap.className = 'chart-canvas-wrap';
|
||
const canvas = document.createElement('canvas');
|
||
canvasWrap.appendChild(canvas);
|
||
wrap.appendChild(canvasWrap);
|
||
chartsDiv.appendChild(wrap);
|
||
|
||
// Downsample for rendering if very long (keep chart responsive)
|
||
const MAX_POINTS = 4000;
|
||
let renderTimes = times;
|
||
let renderData = plotSamples;
|
||
if (plotSamples.length > MAX_POINTS) {
|
||
const step = Math.ceil(plotSamples.length / MAX_POINTS);
|
||
renderTimes = times.filter((_, i) => i % step === 0);
|
||
renderData = plotSamples.filter((_, i) => i % step === 0);
|
||
}
|
||
|
||
const chart = new Chart(canvas, {
|
||
type: 'line',
|
||
data: {
|
||
labels: renderTimes,
|
||
datasets: [{
|
||
data: renderData,
|
||
borderColor: color,
|
||
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 => tooltipFmt(item.raw),
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: 'category',
|
||
ticks: {
|
||
color: '#484f58',
|
||
maxTicksLimit: 10,
|
||
maxRotation: 0,
|
||
callback: (val, i) => renderTimes[i] + ' ms',
|
||
},
|
||
grid: { color: '#21262d' },
|
||
},
|
||
y: {
|
||
// Clamp geo-channel y-axis to ±(0C peak × 1.4) so near-saturation
|
||
// decode artifacts (which inflate autoscale to full range) don't
|
||
// squash the actual blast signal into an invisible flat line.
|
||
// The 0C peak value is authoritative for the true signal amplitude.
|
||
...(isGeo && peak0C !== null && peak0C > 0 ? {
|
||
min: -(peak0C * 1.4),
|
||
max: (peak0C * 1.4),
|
||
} : {}),
|
||
ticks: {
|
||
color: '#484f58',
|
||
maxTicksLimit: 5,
|
||
callback: v => tickFmt(v),
|
||
},
|
||
grid: { color: '#21262d' },
|
||
title: {
|
||
display: true,
|
||
text: yUnit,
|
||
color: '#484f58',
|
||
font: { size: 10 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
plugins: [{
|
||
// Draw trigger line at t=0
|
||
id: 'triggerLine',
|
||
afterDraw(chart) {
|
||
const ctx = chart.ctx;
|
||
const xAxis = chart.scales.x;
|
||
const yAxis = chart.scales.y;
|
||
|
||
// Find index of t=0
|
||
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
|
||
if (zeroIdx < 0) return;
|
||
|
||
const x = xAxis.getPixelForValue(zeroIdx);
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, yAxis.top);
|
||
ctx.lineTo(x, yAxis.bottom);
|
||
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([4, 3]);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
},
|
||
}],
|
||
});
|
||
|
||
charts[ch] = chart;
|
||
}
|
||
}
|
||
|
||
// Auto-detect API base from wherever this page was served from
|
||
document.getElementById('api-base').value = window.location.origin;
|
||
|
||
// Allow Enter key on connection inputs to trigger connect
|
||
['api-base', 'dev-host', 'dev-tcp-port'].forEach(id => {
|
||
document.getElementById(id).addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') connectUnit();
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|