702 lines
24 KiB
HTML
702 lines
24 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;
|
||
}
|
||
|
||
// Clip to total_samples to exclude zero-padding the device appends beyond
|
||
// the configured record window. total = pretrig + post_trig (e.g. 256+3072=3328).
|
||
// decoded may be larger (e.g. 4495) due to trailing zero-padded bulk-stream frames.
|
||
const displayCount = (total > 0 && total < decoded) ? total : decoded;
|
||
|
||
// Build time axis in seconds (matching Blastware event report layout).
|
||
const times = Array.from({ length: displayCount }, (_, i) =>
|
||
((i - pretrig) / sr).toFixed(3)
|
||
);
|
||
|
||
// 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;
|
||
|
||
// Clip channel samples to displayCount (same as time axis)
|
||
const clippedSamples = samples.length > displayCount
|
||
? samples.slice(0, displayCount)
|
||
: samples;
|
||
|
||
// peak0C declared here (function scope) so it is visible in the Chart.js
|
||
// config block below (which lives outside the if(isGeo) block).
|
||
let peak0C = null;
|
||
|
||
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 = clippedSamples.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.
|
||
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(...clippedSamples.map(Math.abs));
|
||
const micScale = (micPeakPsi !== null && peakCounts > 0)
|
||
? Math.abs(micPeakPsi) / peakCounts
|
||
: 1.0;
|
||
plotSamples = clippedSamples.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);
|
||
}
|
||
|
||
let chart;
|
||
try {
|
||
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} s`,
|
||
label: item => tooltipFmt(item.raw),
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: 'category',
|
||
ticks: {
|
||
color: '#484f58',
|
||
maxTicksLimit: 10,
|
||
maxRotation: 0,
|
||
callback: (val, i) => renderTimes[i] + ' s',
|
||
},
|
||
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.
|
||
// Guard: only apply if peak0C is a valid finite positive number.
|
||
...(isGeo && peak0C !== null && peak0C !== undefined
|
||
&& isFinite(peak0C) && 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 the trigger point (t ≥ 0.000 s)
|
||
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();
|
||
},
|
||
}],
|
||
});
|
||
} catch (err) {
|
||
console.error(`Chart.js error for channel ${ch}:`, err);
|
||
canvasWrap.innerHTML = `<p style="color:#f85149;padding:8px;font-size:11px;">Chart error: ${err.message}</p>`;
|
||
}
|
||
|
||
if (chart) 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>
|