Files
repo-smod-atsc/src/web/www/sys_spectrum.html
T

988 lines
49 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="webtitle">Digital Exciter</title>
<script src="js/theme.js"></script>
<link href="css/styles.css" rel="stylesheet" media="screen">
<script src="js/auth.js"></script>
<script src="js/jquery.js"></script>
<script src="js/dashboard.js"></script>
<style>
/* inherit the page's colours: styles.css themes this shell light or dark */
.spec-toolbar { display:flex; flex-wrap:wrap; gap:18px; align-items:center; margin-bottom:10px; }
.spec-toolbar label { font-size:13px; color:inherit; }
.spec-readouts { display:flex; flex-wrap:wrap; gap:10px; margin-top:12px; }
.spec-readout { min-width:110px; padding:6px 8px; border:1px solid rgba(148,163,184,0.35); border-radius:6px; }
.spec-readout-pair { display:flex; gap:10px; flex:0 0 auto; }
.spec-readout .k { display:block; font-size:10px; letter-spacing:.04em; text-transform:uppercase; opacity:.7; }
.spec-readout .v { display:block; font-size:16px; font-weight:600; }
#spectrum_panel .spec-readouts { gap:8px; overflow-x:auto; padding-bottom:2px; }
#spectrum_panel .spec-readout-pair { gap:8px; }
#spec_canvas { width:100%; height:420px; display:block; background:var(--plot-bg); border-radius:6px; }
#eye_canvas { width:100%; height:380px; display:block; background:var(--plot-bg); border-radius:6px; }
#eye_state { font-size:13px; opacity:.7; margin-left:auto; }
#spec_state { font-size:13px; opacity:.7; margin-left:auto; }
.spec-tabs { display:flex; gap:0; border-bottom:1px solid rgba(148,163,184,.35); margin-bottom:16px; }
.spec-tab { appearance:none; border:0; border-bottom:3px solid transparent; background:transparent; color:inherit; cursor:pointer; font:inherit; font-weight:600; padding:10px 18px; }
.spec-tab:hover { background:rgba(148,163,184,.10); }
.spec-tab.active { border-bottom-color:var(--status-neutral); color:var(--status-neutral); }
.spectrum-panel[hidden] { display:none; }
.fssi-refresh-notice { display:none; padding:44px 18px; text-align:center; color:#fbbf24; font-weight:600; }
.spectrum-panel.fssi-refresh-active canvas,
.spectrum-panel.fssi-refresh-active .spec-readouts,
.spectrum-panel.fssi-refresh-active .spec-toolbar { display:none; }
.spectrum-panel.fssi-refresh-active .fssi-refresh-notice { display:block; }
</style>
<script>
var lastTime = new Date().getTime();
var currentTime = new Date().getTime();
var timeOut = 20 * 60 * 1000; // 20 minutes
$(function(){
$(document).on('mousemove keydown click scroll touchstart', function(){
lastTime = new Date().getTime();
});
});
function testTime(){
currentTime = new Date().getTime();
if(currentTime - lastTime > timeOut){
window.location.href="login.cgi?logout=1";
}
}
window.setInterval(testTime, 1000);
setInterval(refreshDashboard, 3000);
function get_remote_local_ctrl()
{
$.get("atsc_status_cfg.cgi?sys_cfg=&", function(data, status){
if (typeof data === 'object' && data !== null) return;
var table = String(data).split("|");
var sysCfg = table[0].split("#");
var title = sysCfg[6] || "Digital Exciter";
document.title = title;
document.getElementById('webtitle').textContent = title;
document.getElementById('webtitle_header').textContent = title;
});
}
function refresh_show(){
get_remote_local_ctrl();
refreshDashboard();
specInit();
}
/* ================= ORx IQ spectrum =================
* iq_spectrum.cgi hands back the newest window analysed by the app's iq_rx
* thread: a 1024-point Welch over one 26.2 ms window of the 10 MSPS complex
* ORx stream. psd[] is in dB relative to the mean in-band flat density
* (|f| < 2.5 MHz) -- the only shoulder yardstick that does not move when the
* FFT length changes. See Notes/Result06-5.md section 7.
*/
var SPEC_TIMER = null;
var specPeak = null; // peak-hold trace
var specLast = null;
var specFails = 0;
var Y_TOP = 30, Y_BOT = -80; // dB rel in-band flat density
var SH_LO = 3.5, SH_HI = 4.8; // shoulder band the app takes the median over
var activeView = 'spectrum';
var fssiRefreshActive = false;
function specInit(){
window.addEventListener('resize', function(){
if (activeView === 'spectrum') {
if (specLast) drawSpectrum(specLast); else drawGrid();
} else if (eyeLastData) {
drawEye(eyeLastData);
}
});
selectSpectrumView('spectrum');
}
function selectSpectrumView(view) {
activeView = (view === 'eye') ? 'eye' : 'spectrum';
var spectrumActive = activeView === 'spectrum';
document.getElementById('spectrum_panel').hidden = !spectrumActive;
document.getElementById('eye_panel').hidden = spectrumActive;
document.getElementById('spectrum_tab').classList.toggle('active', spectrumActive);
document.getElementById('eye_tab').classList.toggle('active', !spectrumActive);
document.getElementById('spectrum_tab').setAttribute('aria-selected', spectrumActive);
document.getElementById('eye_tab').setAttribute('aria-selected', !spectrumActive);
if (fssiRefreshActive) {
setState('FSSI Refresh ON');
setEyeState('FSSI Refresh ON');
return;
}
if (spectrumActive) {
if (EYE_TIMER) clearInterval(EYE_TIMER);
EYE_TIMER = null;
if (specLast) drawSpectrum(specLast); else drawGrid();
specRestart();
} else {
if (SPEC_TIMER) clearInterval(SPEC_TIMER);
SPEC_TIMER = null;
if (eyeLastData) drawEye(eyeLastData);
eyeRestart();
}
}
/* Spectrum controls are shared by both tabs. */
function elChecked(id){ var e = document.getElementById(id); return !!(e && e.checked); }
function specRateMs(){ var e = document.getElementById('spec_rate'); return e ? Number(e.value) : 1000; }
function spectrumControlsChanged(){
if (activeView === 'spectrum') {
specRestart();
} else {
eyeRestart();
if (elChecked('spec_pause')) setEyeState('paused');
}
}
function peakHoldChanged(){
specPeak = null;
if (activeView === 'spectrum' && specLast) drawSpectrum(specLast);
}
function specRestart(){
if (SPEC_TIMER) clearInterval(SPEC_TIMER);
if (activeView !== 'spectrum') return;
if (fssiRefreshActive) { setState('FSSI Refresh ON'); return; }
if (elChecked('spec_pause')) { setState('paused'); return; }
specFetch();
SPEC_TIMER = setInterval(specFetch, specRateMs());
}
function setState(s){ var e = document.getElementById('spec_state'); if (e) e.innerText = s; }
function setEyeState(s){ var e = document.getElementById('eye_state'); if (e) e.innerText = s; }
window.setSpectrumRefreshActive = function(active){
active = !!active;
if (fssiRefreshActive === active) return;
fssiRefreshActive = active;
document.getElementById('spectrum_panel').classList.toggle('fssi-refresh-active', active);
document.getElementById('eye_panel').classList.toggle('fssi-refresh-active', active);
if (active) {
if (SPEC_TIMER) clearInterval(SPEC_TIMER);
if (EYE_TIMER) clearInterval(EYE_TIMER);
SPEC_TIMER = null;
EYE_TIMER = null;
setState('FSSI Refresh ON');
setEyeState('FSSI Refresh ON');
} else if (activeView === 'spectrum') {
specRestart();
} else {
eyeRestart();
}
};
function specFetch(){
$.ajax({url: 'iq_spectrum.cgi?t=' + Date.now(), dataType: 'json', cache: false})
.done(function(d){
if (!d || d.error){
if (d && d.redirect) { window.location.href = d.redirect; return; }
setState(d && d.error === 'nodata' ? 'no window captured yet' : 'error');
return;
}
if (fssiRefreshActive) return;
specFails = 0;
specLast = d;
if (activeView === 'spectrum') {
updateReadouts(d);
drawSpectrum(d);
setState('window ' + d.wid + ' | updated ' + new Date().toLocaleTimeString());
}
})
.fail(function(){ specFails++; setState('fetch failed (' + specFails + ')'); });
}
function updateReadouts(d){
/* R7 (2026-08-11) + R10 (2026-08-12): apply the documented 11 dB pilot offset
so these readouts match the dashboard's LIMD/UIMD and the 1.0 convention.
SIGN CONVENTION (R10, two-level hardware-verified): d.limd/d.uimd are POSITIVE
attenuation magnitudes -> offset is +11. d.shoulder is a SIGNED level below
in-band flat density (negative) -> offset is -11 (more suppression = more
negative). Verified: raw -22.7 -> -33.7 vs IMD 32.3/32.5; raw ~-63.4 -> -74.4
vs IMD 73.6/73.9. */
var IMD_PILOT_OFFSET = 11; // dB; documented pilot offset (1.0 status_info_new.html)
setReadout('rd_shoulder', d.shoulder - IMD_PILOT_OFFSET, 1);
setReadout('rd_limd', d.limd + IMD_PILOT_OFFSET, 1);
setReadout('rd_uimd', d.uimd + IMD_PILOT_OFFSET, 1);
setReadout('rd_pilot', d.pilot, 4);
setReadout('rd_peak', d.peak, 1);
setReadout('rd_rbw', d.rbw_khz, 2);
setReadout('rd_ms', d.ms, 2);
var packets = document.getElementById('rd_pkts');
if (packets) {
packets.innerText = (d.pkts === undefined || d.npkt === undefined) ? '--' : d.pkts + '/' + d.npkt;
packets.style.color = (d.pkts === d.npkt) ? '' : 'var(--status-err)';
}
var field = document.getElementById('rd_field');
var contiguous = readNumber(d.ms);
if (field) {
field.innerText = (contiguous === null) ? '--' : (contiguous > 24.2 ? 'yes' : 'no');
field.style.color = (contiguous === null) ? '' : (contiguous > 24.2 ? 'var(--accent-green)' : 'var(--status-err)');
}
}
function readNumber(value){
var number = Number(value);
return isFinite(number) ? number : null;
}
function setReadout(id, value, decimals){
var element = document.getElementById(id);
var number = readNumber(value);
if (element) element.innerText = (number === null) ? '--' : number.toFixed(decimals);
return number;
}
/* ================= 8VSB eye =================
* iq_eye.cgi returns the newest window demodulated by the app's iq_vsb thread:
* matched filter -> j^k derotation -> 130 x 192 persistence map (base64, log-scaled).
* The demod is expensive and nice'd, so a new eye lands roughly every 10 s; the page
* polls at the same rate as the spectrum and redraws only when the window id changes.
*/
var EYE_TIMER = null, eyeWid = -1, eyeFails = 0;
var eyeLastData = null; // newest iq_eye.json, for the in-band noise line
function eyeRestart(){
if (EYE_TIMER) clearInterval(EYE_TIMER);
if (activeView !== 'eye') return;
if (fssiRefreshActive) { setEyeState('FSSI Refresh ON'); return; }
if (elChecked('spec_pause')) return;
eyeFetch();
EYE_TIMER = setInterval(eyeFetch, specRateMs());
}
function eyeFetch(){
$.ajax({url: 'iq_eye.cgi?t=' + Date.now(), dataType: 'json', cache: false})
.done(function(d){
if (!d || d.error){
if (d && d.redirect) { window.location.href = d.redirect; return; }
setEyeState((d && d.error === 'nodata') ? 'no window demodulated yet' : 'error');
return;
}
if (!d.sync){ setEyeState('no symbol lock'); return; }
if (fssiRefreshActive) return;
eyeFails = 0;
eyeLastData = d;
if (activeView === 'eye') {
eyeReadouts(d);
if (d.wid !== eyeWid){ eyeWid = d.wid; drawEye(d); }
setEyeState('window ' + d.wid + ' | ' + d.nsym + ' symbols');
}
})
.fail(function(){ eyeFails++; setEyeState('fetch failed (' + eyeFails + ')'); });
}
function eyeReadouts(d){
var eyeHeights = Array.isArray(d.eye_h) ? d.eye_h.map(readNumber).filter(function(value){ return value !== null; }) : [];
var sigma = Array.isArray(d.sigma) ? d.sigma.map(readNumber).filter(function(value){ return value !== null; }) : [];
var minh = eyeHeights.length ? Math.min.apply(null, eyeHeights) : null;
var sAvg = sigma.length ? sigma.reduce(function(a, b){ return a + b; }, 0) / sigma.length : null;
var snr = setReadout('rd_snr', d.snr_db, 2);
setReadout('rd_eyeh', minh, 2);
setReadout('rd_eyew', d.eye_w_ui, 3);
setReadout('rd_tau', d.tau, 4);
setReadout('rd_segsync', d.segsync_ratio, 1);
setReadout('rd_coff', d.carrier_off_hz, 3);
setReadout('rd_snrraw', d.snr_raw_db, 2);
var jitter = setReadout('rd_gjit', d.gain_jitter_pct, 2);
var sn = document.getElementById('rd_snr');
if (sn) sn.style.color = (snr === null) ? '' : (snr >= 27 ? 'var(--accent-green)' : (snr >= 20 ? 'var(--status-warn)' : 'var(--status-err)'));
var height = document.getElementById('rd_eyeh');
if (height) height.style.color = (minh === null || sAvg === null) ? '' :
((minh > 0.6 && minh > 2 - 6 * sAvg - 0.35) ? 'var(--accent-green)' : (minh > 0.3 ? 'var(--status-warn)' : 'var(--status-err)'));
var jitterElement = document.getElementById('rd_gjit');
if (jitterElement) jitterElement.style.color = (jitter === null) ? '' :
(jitter < 1.0 ? 'var(--accent-green)' : (jitter < 4 ? 'var(--status-warn)' : 'var(--status-err)'));
}
function drawEye(d){
var P = PLOT();
var c = document.getElementById('eye_canvas');
var dpr = window.devicePixelRatio || 1;
var W = c.clientWidth, H = c.clientHeight;
if (c.width !== Math.round(W*dpr) || c.height !== Math.round(H*dpr)){
c.width = Math.round(W*dpr); c.height = Math.round(H*dpr);
}
var g = c.getContext('2d');
g.setTransform(dpr,0,0,dpr,0,0);
g.clearRect(0,0,W,H);
g.fillStyle = P.bg; g.fillRect(0,0,W,H);
var cols = d.eye.cols, rows = d.eye.rows, amp = d.eye.amp;
var bin = atob(d.eye.data), x, y;
/* Build the histogram off-screen at its native size and let the browser scale it:
* one putImageData beats stroking a thousand paths. */
var off = document.createElement('canvas');
off.width = cols; off.height = rows;
var og = off.getContext('2d');
var img = og.createImageData(cols, rows);
for (x = 0; x < cols; x++){
for (y = 0; y < rows; y++){
var v = bin.charCodeAt(x * rows + y) / 255; /* column-major, row 0 = -amp */
var p = ((rows - 1 - y) * cols + x) * 4; /* canvas y grows downward */
img.data[p] = Math.min(255, 40 + 255 * Math.pow(v, 0.75));
img.data[p + 1] = Math.min(255, 20 + 200 * Math.pow(v, 1.6));
img.data[p + 2] = Math.min(255, 60 + 120 * Math.pow(v, 2.6));
img.data[p + 3] = v > 0 ? 255 : 0;
}
}
og.putImageData(img, 0, 0);
var L = 46, R = 12, T = 10, B = 28;
g.imageSmoothingEnabled = true;
g.drawImage(off, L, T, W - L - R, H - T - B);
function yOfLvl(v){ return T + (amp - v) / (2 * amp) * (H - T - B); }
g.strokeStyle = P.eyeLevel; g.lineWidth = 0.7;
[-7,-5,-3,-1,1,3,5,7].forEach(function(v){
var yy = yOfLvl(v);
g.beginPath(); g.moveTo(L,yy); g.lineTo(W-R,yy); g.stroke();
});
var xc = L + (W - L - R) / 2; /* decision instant = centre column */
g.strokeStyle = P.decision; g.setLineDash([4,3]);
g.beginPath(); g.moveTo(xc,T); g.lineTo(xc,H-B); g.stroke();
g.setLineDash([]);
g.fillStyle = P.axisLbl; g.font = '11px sans-serif';
g.textAlign = 'right'; g.textBaseline = 'middle';
[-7,-5,-3,-1,1,3,5,7].forEach(function(v){ g.fillText(v.toFixed(0), L-6, yOfLvl(v)); });
g.textAlign = 'center'; g.textBaseline = 'top';
[-1,-0.5,0,0.5,1].forEach(function(u){
g.fillText(u.toFixed(1), L + (u+1)/2*(W-L-R), H-B+5);
});
g.fillStyle = P.axisTtl; g.textBaseline = 'bottom';
g.fillText('symbol periods (UI) - decision instant at 0', (L+W-R)/2, H-2);
g.save(); g.translate(12,(T+H-B)/2); g.rotate(-Math.PI/2);
g.textBaseline = 'top'; g.fillText('normalised I level', 0, 0); g.restore();
}
/* ---- canvas ---- */
/* -----------------------------------------------------------------------
R5 (2026-08-11): colour lives in the stylesheet, never in this file.
Read once per draw and cached until the theme class changes -- at a 1 Hz
redraw the cost is nil, and it means a family colour ruling reaches these
plots without anyone editing JavaScript again.
Each read carries the pre-R5 literal as its fallback ON PURPOSE: this
product has already shipped a unit running a STALE styles.css against a
correct package (S3, the white-banner round). If that happens again the
plots degrade to exactly today's appearance instead of to black.
----------------------------------------------------------------------- */
var PLOT = (function(){
var cache = null, cacheKey = null;
return function(){
var key = document.documentElement.className;
if (cache && cacheKey === key) return cache;
var cs = getComputedStyle(document.documentElement);
function v(name, fallback){
var got = cs.getPropertyValue(name);
got = got ? got.trim() : '';
return got || fallback;
}
cache = {
bg: v('--plot-bg', '#0b1220'),
grid: v('--plot-grid', '#1e293b'),
zero: v('--plot-grid-zero', '#334155'),
axisLbl: v('--plot-axis-label', '#94a3b8'),
axisTtl: v('--plot-axis-title', '#64748b'),
trace: v('--plot-trace', '#38bdf8'),
hold: v('--plot-trace-hold', '#f59e0b'),
marker: v('--plot-marker', '#f87171'),
band: v('--plot-band', 'rgba(56,189,248,0.10)'),
eyeLevel: v('--plot-eye-level', 'rgba(57,208,255,0.45)'),
decision: v('--plot-eye-decision', '#a3e635'),
traceError: v('--plot-trace-error', '#eab308')
};
cacheKey = key;
return cache;
};
})();
/* NOTE: the eye's density colormap (40+255v^0.75 / 20+200v^1.6 / 60+120v^2.6)
is an ALGORITHM, not a palette entry, and is left exactly as shipped. It is
the reason the plot field stays dark in both themes -- see styles.css. */
function specGeom(){
var c = document.getElementById('spec_canvas');
var dpr = window.devicePixelRatio || 1;
var w = c.clientWidth, h = c.clientHeight;
if (c.width !== Math.round(w*dpr) || c.height !== Math.round(h*dpr)){
c.width = Math.round(w*dpr); c.height = Math.round(h*dpr);
}
var g = c.getContext('2d');
g.setTransform(dpr,0,0,dpr,0,0);
return {g:g, w:w, h:h, l:56, r:14, t:14, b:30};
}
function xOf(m, f, fmin, fmax){ return m.l + (f - fmin)/(fmax - fmin) * (m.w - m.l - m.r); }
function yOf(m, db){ return m.t + (Y_TOP - db)/(Y_TOP - Y_BOT) * (m.h - m.t - m.b); }
function drawGrid(fmin, fmax){
var P = PLOT();
if (fmin === undefined){ fmin = -5; fmax = 5; }
var m = specGeom(), g = m.g, f, db;
g.clearRect(0,0,m.w,m.h);
g.fillStyle = P.bg; g.fillRect(0,0,m.w,m.h);
g.fillStyle = P.band; // the band the shoulder is measured in
[[SH_LO,SH_HI],[-SH_HI,-SH_LO]].forEach(function(b){
var x0 = xOf(m,Math.max(b[0],fmin),fmin,fmax), x1 = xOf(m,Math.min(b[1],fmax),fmin,fmax);
g.fillRect(x0, m.t, x1-x0, m.h-m.t-m.b);
});
g.lineWidth = 1;
g.font = '11px sans-serif';
g.textAlign = 'center'; g.textBaseline = 'top';
for (f = Math.ceil(fmin); f <= fmax; f++){
var x = Math.round(xOf(m,f,fmin,fmax)) + 0.5;
g.strokeStyle = P.grid;
g.beginPath(); g.moveTo(x,m.t); g.lineTo(x,m.h-m.b); g.stroke();
g.fillStyle = P.axisLbl; g.fillText(f.toFixed(0), x, m.h-m.b+5);
}
g.textAlign = 'right'; g.textBaseline = 'middle';
for (db = Y_TOP; db >= Y_BOT; db -= 10){
var y = Math.round(yOf(m,db)) + 0.5;
g.strokeStyle = (db === 0) ? P.zero : P.grid;
g.beginPath(); g.moveTo(m.l,y); g.lineTo(m.w-m.r,y); g.stroke();
g.fillStyle = P.axisLbl; g.fillText(db.toFixed(0), m.l-6, y);
}
g.fillStyle = P.axisTtl; g.textAlign = 'center'; g.textBaseline = 'bottom';
g.fillText('MHz from carrier', (m.l+m.w-m.r)/2, m.h-2);
g.save(); g.translate(12,(m.t+m.h-m.b)/2); g.rotate(-Math.PI/2);
g.textBaseline = 'top'; g.fillText('dB rel. in-band flat density', 0, 0); g.restore();
return m;
}
function trace(m, psd, f0, df, fmin, fmax, color, width){
var g = m.g, i, started = false;
g.strokeStyle = color; g.lineWidth = width; g.beginPath();
for (i = 0; i < psd.length; i++){
var f = f0 + i*df;
if (f < fmin || f > fmax) continue;
var x = xOf(m,f,fmin,fmax), y = yOf(m, Math.max(Math.min(psd[i],Y_TOP),Y_BOT));
if (!started){ g.moveTo(x,y); started = true; } else g.lineTo(x,y);
}
g.stroke();
}
function drawSpectrum(d){
var P = PLOT();
var fmin = d.f0, fmax = d.f0 + (d.n-1)*d.df;
var m = drawGrid(fmin, fmax), i;
if (elChecked('spec_hold')){
if (!specPeak || specPeak.length !== d.psd.length) specPeak = d.psd.slice();
else for (i = 0; i < d.psd.length; i++) if (d.psd[i] > specPeak[i]) specPeak[i] = d.psd[i];
trace(m, specPeak, d.f0, d.df, fmin, fmax, P.hold, 1);
} else specPeak = null;
trace(m, d.psd, d.f0, d.df, fmin, fmax, P.trace, 1.2);
var y = yOf(m, d.shoulder); // measured shoulder level
m.g.strokeStyle = P.marker; m.g.setLineDash([5,4]); m.g.lineWidth = 1;
m.g.beginPath(); m.g.moveTo(m.l,y); m.g.lineTo(m.w-m.r,y); m.g.stroke();
m.g.setLineDash([]);
m.g.fillStyle = P.marker; m.g.textAlign = 'left'; m.g.textBaseline = 'bottom';
m.g.fillText('shoulder ' + (d.shoulder - 11).toFixed(1) + ' dBc', m.l+6, y-3);
/* PR12: pilot vertical marker removed on request (it covered the pilot
* peak). The pilot frequency is still available in d.pilot. */
/* PR12: error signal spectrum (yellow) = reshaped decisions subtracted
* from the actual IQ, in the channel frame so it lines up with the blue
* trace. Full-band residual (shows in-band distortion AND out-of-band
* shoulder). Drawn only where it stays below the blue PSD -- any point
* above the signal breaks the segment (clip, not clamp). */
/* DISABLED on request (2026-07-25): drop the leading `false &&` to restore */
if (false && eyeLastData && eyeLastData.err && eyeLastData.err.db){
var er = eyeLastData.err, g2 = m.g, started = false, ei;
g2.strokeStyle = P.traceError; g2.lineWidth = 1.4; g2.beginPath();
for (ei = 0; ei < er.db.length; ei++){
var ef = er.f0 + ei*er.df;
if (ef < fmin || ef > fmax){ started = false; continue; }
/* blue signal level at this frequency (nearest published bin) */
var bi = Math.round((ef - d.f0)/d.df);
var sig = (bi >= 0 && bi < d.psd.length) ? d.psd[bi] : 99;
if (er.db[ei] > sig){ started = false; continue; } /* above signal: hide */
var ex = xOf(m, ef, fmin, fmax),
ey = yOf(m, Math.max(Math.min(er.db[ei], Y_TOP), Y_BOT));
if (!started){ g2.moveTo(ex, ey); started = true; } else g2.lineTo(ex, ey);
}
g2.stroke();
/* PR12: yellow error trace has no text label on request */
}
}
// Opens the modal - gets current username from server
function openAccountModal() {
// Fetch current username from server
$.get("login.cgi?action=get_user", function(data) {
var trimmedData = (data || "").trim();
if (trimmedData && trimmedData !== "UNAUTHORIZED") {
document.getElementById('current_user').value = trimmedData;
}
document.getElementById('accountModal').style.display = 'block';
setTimeout(function() { document.getElementById('current_pwd').focus(); }, 100);
}).fail(function() {
document.getElementById('accountModal').style.display = 'block';
setTimeout(function() { document.getElementById('current_pwd').focus(); }, 100);
});
}
// Closes the modal and clears the sensitive inputs
function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none';
document.getElementById('current_pwd').value = '';
document.getElementById('new_pwd').value = '';
document.getElementById('confirm_pwd').value = '';
}
// Validates that a string contains only printable characters (excluding space)
function isValidPrintableString(str) {
// Regular expression to check for printable characters (ASCII 33-126), excluding space
// Space is ASCII 32, and we want to exclude it
var printableCharRegex = /^[!-~]+$/; // ! is ASCII 33, ~ is ASCII 126
return printableCharRegex.test(str);
}
// Validates the passwords match before submitting to the CGI script - All required fields must be filled
function validateAccountForm() {
var currentUser = document.getElementById('current_user').value;
var currentPwd = document.getElementById('current_pwd').value;
var newUsername = document.getElementById('new_user').value;
var newPwd = document.getElementById('new_pwd').value;
var confirmPwd = document.getElementById('confirm_pwd').value;
// Current password is required (cannot be empty)
if (!currentPwd || currentPwd.trim() === "") {
alert("Current password is required");
return false;
}
// Validate current password contains only printable characters
if (!isValidPrintableString(currentPwd)) {
alert("Current password contains invalid characters. Only printable characters (excluding space) are allowed.");
return false;
}
// New password is required (cannot be empty)
if (!newPwd || newPwd.trim() === "") {
alert("New password is required");
return false;
}
// Validate new password contains only printable characters
if (!isValidPrintableString(newPwd)) {
alert("New password contains invalid characters. Only printable characters (excluding space) are allowed.");
return false;
}
// Validate new password length (max 16 chars)
if (newPwd.length > 16) {
alert("New password must be no more than 16 characters");
return false;
}
// Validate new password length
if (newPwd.length < 6) {
alert("New password must be at least 6 characters");
return false;
}
// Confirm password must match new password
if (newPwd !== confirmPwd) {
alert("New passwords do not match");
return false;
}
// Validate confirm password contains only printable characters
if (!isValidPrintableString(confirmPwd)) {
alert("Confirm password contains invalid characters. Only printable characters (excluding space) are allowed.");
return false;
}
// Validate confirm password length (max 16 chars)
if (confirmPwd.length > 16) {
alert("Confirm password must be no more than 16 characters");
return false;
}
// Validate new username if provided
if (newUsername && newUsername.trim() !== "") {
// Validate new username contains only printable characters
if (!isValidPrintableString(newUsername)) {
alert("New username contains invalid characters. Only printable characters (excluding space) are allowed.");
return false;
}
// Validate new username length (max 16 chars)
if (newUsername.length > 16) {
alert("New username must be no more than 16 characters");
return false;
}
}
return true; // Allows the form to submit
}
// Form submission handler using fetch API
document.addEventListener('DOMContentLoaded', function() {
var accountForm = document.getElementById('accountForm');
if (accountForm) {
accountForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!validateAccountForm()) return;
var formData = new URLSearchParams({
Username: document.getElementById('current_user').value,
OldPassword: document.getElementById('current_pwd').value,
NewUsername: document.getElementById('new_user').value,
NewPassword: document.getElementById('new_pwd').value,
ConfirmPassword: document.getElementById('confirm_pwd').value
});
fetch('login.cgi?action=change_account', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert("Update successful! Please login again.");
closeAccountModal();
// Redirect to login page after 1.5 seconds
setTimeout(function() {
window.location.href = 'login.cgi?logout=1';
}, 1500);
} else {
alert(data.error || "Update failed");
}
})
.catch(function(err) {
alert("Network error: " + err);
});
});
}
});
// Optional: Closes the modal if the user clicks the dark background outside the box
window.onclick = function(event) {
var modal = document.getElementById('accountModal');
if (event.target == modal) {
closeAccountModal();
}
}
</script>
</head>
<body onload="refresh_show()">
<div class="app-layout">
<aside class="app-sidebar">
<ul class="items">
<li><a href="overview.html" title="Overview">Overview</a></li>
<li><a href="status_info.html" title="Status">Status</a></li>
<li><a href="sys_set.html" title="Settings">Settings</a></li>
<li><a href="sys_rf.html" title="RF Control">RF Control</a></li>
<li><a href="sys_psip.html" title="TSID&PSIP">TSID&PSIP</a></li>
<li><a href="sys_ts.html" title="TSoIP">TSoIP</a></li>
<li class="active"><a href="sys_spectrum.html" title="RF Performance">RF Performance</a></li>
<li><a href="log_output.html" title="Log">Log</a></li>
</ul>
<div class="sidebar-footer">
<h4>Anywave Broadcast Inc.</h4>
<p>Copyright 2026<br><br><span class="redbox-label">Technical Support</span><br>+1 847 415 2258<br><a href="mailto:support@anywaveus.com">support@anywaveus.com</a></p>
</div>
</aside>
<div class="sidebar-overlay" onclick="toggleSidebar()"></div>
<header class="app-header">
<button class="menu-toggle" onclick="toggleSidebar()" aria-label="Toggle menu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<h1><a href="https://anywavecom.net/" class="logo-bg" title="Anywave Broadcast Inc."></a></h1>
<h2 id="webtitle_header"><span></span></h2>
<h3 class="has-toggle">
<div class="utility-cluster">
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Switch color theme" title="Switch color theme">
<span class="tt-sun" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></span>
<span class="tt-moon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></span>
<span class="tt-label"><span class="tt-to-light">Light Mode</span><span class="tt-to-dark">Dark Mode</span></span>
</button>
<span class="utility-divider" aria-hidden="true"></span>
<div class="dropdown">
<button class="dropbtn-modern">Admin &#9662;</button>
<div class="dropdown-content">
<a href="#" onclick="openAccountModal(); return false;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06-.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06-.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
Account Settings
</a>
<a href="login.cgi?logout=1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Logout
</a>
</div>
</div>
</div>
</h3>
</header>
<!-- Fixed Dashboard Summary - Top of page, under header -->
<div class="dashboard-fixed">
<div class="summary-dashboard">
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-neutral" id="dot_freq" aria-hidden="true"></span>
<span class="summary-label">Frequency</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-neutral" id="sum_freq">--</span>
<span class="summary-unit">MHz</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-ok" id="dot_snr" aria-hidden="true"></span>
<span class="summary-label">SNR</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-ok" id="sum_snr">--</span>
<span class="summary-unit">dB</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-ok" id="dot_limd" aria-hidden="true"></span>
<span class="summary-label">LIMD</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-ok" id="sum_limd">--</span>
<span class="summary-unit">dBc</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-ok" id="dot_uimd" aria-hidden="true"></span>
<span class="summary-label">UIMD</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-ok" id="sum_uimd">--</span>
<span class="summary-unit">dBc</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-off" id="dot_rf" aria-hidden="true"></span>
<span class="summary-label">RF Switch</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-muted" id="sum_rf">--</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-ok" id="dot_outpow" aria-hidden="true"></span>
<span class="summary-label">Output Power</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-ok" id="sum_outpow">--</span>
<span class="summary-unit">dBm</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-neutral" id="dot_input" aria-hidden="true"></span>
<span class="summary-label">Input Source</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-neutral" id="sum_input">--</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-neutral" id="dot_input_status" aria-hidden="true"></span>
<span class="summary-label">Input Status</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-neutral" id="sum_input_status">--</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-neutral" id="dot_bitrate" aria-hidden="true"></span>
<span class="summary-label">Bitrate</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-neutral" id="sum_bitrate">--</span>
<span class="summary-unit">Mbps</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-ok" id="dot_temp" aria-hidden="true"></span>
<span class="summary-label">Temperature</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-ok" id="sum_temp">--</span>
<span class="summary-unit">&#176;F</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-off" id="dot_agc" aria-hidden="true"></span>
<span class="summary-label">AGC</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-muted" id="sum_agc">--</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label-row">
<span class="status-dot status-neutral" id="dot_control" aria-hidden="true"></span>
<span class="summary-label">Control</span>
</div>
<div class="summary-value-wrapper">
<span class="summary-value value-neutral" id="sum_control">--</span>
</div>
</div>
</div>
</div>
<main class="app-main">
<div class="spec-tabs" role="tablist" aria-label="Spectrum display">
<button type="button" class="spec-tab active" id="spectrum_tab" role="tab" aria-selected="true" onclick="selectSpectrumView('spectrum')">Output Spectrum</button>
<button type="button" class="spec-tab" id="eye_tab" role="tab" aria-selected="false" onclick="selectSpectrumView('eye')">8VSB Eye Diagram</button>
</div>
<section class="spectrum-panel" id="spectrum_panel" role="tabpanel" aria-labelledby="spectrum_tab">
<div class="box">
<div class="fssi-refresh-notice">FSSI Refresh is ON. Spectrum and Eye Diagram are suspended.</div>
<div class="spec-toolbar">
<label>Refresh
<select id="spec_rate" onchange="spectrumControlsChanged()">
<option value="500">0.5 s</option>
<option value="1000" selected>1 s</option>
<option value="2000">2 s</option>
<option value="5000">5 s</option>
</select>
</label>
<label><input type="checkbox" id="spec_pause" onchange="spectrumControlsChanged()"> Pause</label>
<label><input type="checkbox" id="spec_hold" onchange="peakHoldChanged()"> Peak hold</label>
<span id="spec_state">starting&hellip;</span>
</div>
<canvas id="spec_canvas"></canvas>
<div class="spec-readouts">
<div class="spec-readout" title="ORx front-end signal strength from the FAGC gain index. A path; A / B when the FSSI refresh switch is on."><span class="k">FSSI</span><span class="v"><span id="rd_fssi">--</span> %</span></div>
<div class="spec-readout" title="Shoulder level, dBc (signed; includes the −11 dB pilot offset)."><span class="k">Shoulder</span><span class="v"><span id="rd_shoulder">--</span> dBc</span></div>
<div class="spec-readout-pair">
<div class="spec-readout" title="Lower shoulder IMD, dBc (includes the +11 dB pilot offset; matches the dashboard LIMD)."><span class="k">LIMD</span><span class="v"><span id="rd_limd">--</span> dBc</span></div>
<div class="spec-readout" title="Upper shoulder IMD, dBc (includes the +11 dB pilot offset; matches the dashboard UIMD)."><span class="k">UIMD</span><span class="v"><span id="rd_uimd">--</span> dBc</span></div>
</div>
<div class="spec-readout"><span class="k">Pilot</span><span class="v"><span id="rd_pilot">--</span> MHz</span></div>
<div class="spec-readout"><span class="k">Peak</span><span class="v"><span id="rd_peak">--</span> dB</span></div>
<div class="spec-readout"><span class="k">RBW</span><span class="v"><span id="rd_rbw">--</span> kHz</span></div>
<div class="spec-readout"><span class="k">Packets</span><span class="v" id="rd_pkts">--</span></div>
<div class="spec-readout"><span class="k">Contiguous</span><span class="v"><span id="rd_ms">--</span> ms</span></div>
<div class="spec-readout"><span class="k">&gt; 1 field</span><span class="v" id="rd_field">--</span></div>
</div>
</div>
</section>
<section class="spectrum-panel" id="eye_panel" role="tabpanel" aria-labelledby="eye_tab" hidden>
<div class="box">
<div class="fssi-refresh-notice">FSSI Refresh is ON. Spectrum and Eye Diagram are suspended.</div>
<!-- PR12: eye caption + status line removed on request. -->
<canvas id="eye_canvas"></canvas>
<div class="spec-readouts">
<div class="spec-readout" title="MER, I-path definition: 10log10(sum d^2 / sum (r-d)^2)"><span class="k">SNR / MER</span><span class="v"><span id="rd_snr">--</span> dB</span></div>
<div class="spec-readout" title="Smallest of the 7 eye openings at the decision instant."><span class="k">Min eye height</span><span class="v" id="rd_eyeh">--</span></div>
<div class="spec-readout" title="Time span over which the central eye stays open."><span class="k">Eye width</span><span class="v"><span id="rd_eyew">--</span> UI</span></div>
<div class="spec-readout" title="Sampling phase offset."><span class="k">&tau;0</span><span class="v"><span id="rd_tau">--</span> sa</span></div>
<div class="spec-readout" title="Segment synchronization correlation ratio."><span class="k">Seg sync</span><span class="v" id="rd_segsync">--</span></div>
<div class="spec-readout" title="Pilot phase drift across the window."><span class="k">Carrier offset</span><span class="v"><span id="rd_coff">--</span> Hz</span></div>
<div class="spec-readout" title="MER before ORx AGC gain steps are tracked out."><span class="k">SNR raw</span><span class="v"><span id="rd_snrraw">--</span> dB</span></div>
<div class="spec-readout" title="RMS of ORx RF AGC gain steps over the window."><span class="k">ORx AGC jitter</span><span class="v"><span id="rd_gjit">--</span> %</span></div>
</div>
</div>
</section>
</main>
</div>
<div id="accountModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Account Settings</h3>
<span class="close" onclick="closeAccountModal()">&times;</span>
</div>
<div class="modal-body">
<form id="accountForm" method="post" action="login.cgi?action=change_account" onsubmit="return validateAccountForm()">
<table class="modal-table">
<tr>
<td>Current Username</td>
<td><input type="text" id="current_user" name="current_user" readonly></td>
</tr>
<tr>
<td>Current Password</td>
<td><input type="password" id="current_pwd" name="current_pwd" required></td>
</tr>
<tr>
<td>New Username</td>
<td><input type="text" id="new_user" name="new_user"></td>
</tr>
<tr>
<td>New Password</td>
<td><input type="password" id="new_pwd" name="new_pwd"></td>
</tr>
<tr>
<td>Confirm Password</td>
<td><input type="password" id="confirm_pwd" name="confirm_pwd"></td>
</tr>
</table>
<div class="modal-footer">
<input type="button" class="btn-modern" style="background-color: #64748b;" value="Cancel" onclick="closeAccountModal()">
<input type="submit" class="btn-modern" value="Save Changes">
</div>
</form>
</div>
</div>
</div>
<script>
function toggleSidebar(){
document.querySelector('.app-sidebar').classList.toggle('sidebar-open');
document.querySelector('.sidebar-overlay').classList.toggle('active');
}
</script>
</body>
</html>