mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +00:00
Add 'dup' field to state JSON (FDisplayDuplex). In CTUN+non-DUP TX, use vfo_a_hz as spectrum center so the TX signal renders at the VFO position instead of the display center. In CTUN+DUP TX, keep center_hz so the RX spectrum stays correctly positioned at the filter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
600 lines
45 KiB
ObjectPascal
600 lines
45 KiB
ObjectPascal
unit WebPageHtml;
|
||
|
||
{
|
||
WebPageHtml.pas — Встроенный HTML/CSS/JS интерфейс веб-пульта HPSDR.
|
||
|
||
Содержит единственную функцию GetIndexHtml, которая возвращает полную
|
||
HTML-страницу (одним файлом) для отправки клиенту по HTTP GET /.
|
||
|
||
ИСПРАВЛЕНИЯ:
|
||
- Кнопка START/STOP добавлена в тулбар 1 (шлёт set_run, зелёная/красная)
|
||
- Кнопка RX/TX удалена из тулбара 2
|
||
- NR/NB/ANF: cur обновляется локально сразу при клике (иначе повторный
|
||
клик инвертировал уже отправленное значение)
|
||
- Opus: decodeFrame() правильный метод, async/await
|
||
- Opus: opQueue буферизует пакеты пока WASM не готов
|
||
- Opus: AudioContext создаётся только по user gesture (оверлей)
|
||
- SPANS: только 48K..1536K
|
||
- (Mode fix) modTglBtn теперь отображает ТЕКУЩИЙ активный режим
|
||
(LSB/USB, CWL/CWU, AM/SAM, FM), а не следующий. При клике
|
||
переключает на парный режим внутри группы.
|
||
- (Mode fix) cwBtn удалён — его роль выполняет modTglBtn при CW группе.
|
||
- (Mode fix) syncModGrp + updModTglLabel корректно синхронизируют UI
|
||
из server state: выпадающий список и кнопка отражают десктоп.
|
||
- (Mode fix) modGrpSel.onchange при смене группы сразу отправляет
|
||
команду mode с дефолтным режимом группы (LSB, CWL, FM, AM).
|
||
}
|
||
|
||
{$IFDEF FPC}
|
||
{$MODE Delphi}
|
||
{$LONGSTRINGS ON}
|
||
{$ENDIF}
|
||
|
||
interface
|
||
|
||
function GetIndexHtml: string;
|
||
|
||
implementation
|
||
|
||
function GetIndexHtml: string;
|
||
begin
|
||
Result :=
|
||
'<!DOCTYPE html><html lang="en"><head>' +
|
||
'<meta charset="UTF-8">' +
|
||
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
||
'<title>HPSDR Web Remote v2</title>' +
|
||
'<link rel="preconnect" href="https://fonts.googleapis.com">' +
|
||
'<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400&family=Share+Tech+Mono&display=swap" rel="stylesheet">' +
|
||
'<style>' +
|
||
'*{box-sizing:border-box;margin:0;padding:0}' +
|
||
'body{background:#141414;color:#ccc;font-family:"Segoe UI",Tahoma,sans-serif;font-size:12px;overflow:hidden;height:100vh}' +
|
||
'.wrap{display:flex;flex-direction:column;height:100vh;gap:2px;padding:2px}' +
|
||
'@import url(''https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500&display=swap'');' +
|
||
'.tbar{display:flex;align-items:center;gap:5px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:3px;padding:2px 6px;flex-shrink:0;height:32px}' +
|
||
'.tsep{flex:1}' +
|
||
'button{background:linear-gradient(180deg,#404040,#2c2c2c);color:#ccc;border:1px solid #505050;border-radius:3px;padding:0 9px;font-size:11px;cursor:pointer;height:22px;white-space:nowrap;line-height:1;display:inline-flex;align-items:center;justify-content:center}' +
|
||
'button:hover{background:linear-gradient(180deg,#4c4c4c,#383838)}' +
|
||
'button.on{background:linear-gradient(180deg,#1e4d1e,#163216);border-color:#3d7a3d;color:#7fff7f}' +
|
||
'button.rx-tx{background:linear-gradient(180deg,#4d1e1e,#321616);border-color:#7a3d3d;color:#ff8080}' +
|
||
'button.mox-on{background:linear-gradient(180deg,#6d1e1e,#4d0a0a);border-color:#cc3333;color:#ffaaaa;font-weight:bold}' +
|
||
'select{background:#222;color:#bbb;border:1px solid #484848;border-radius:3px;padding:0 4px;font-size:11px;height:22px;cursor:pointer}' +
|
||
'.sl-g{display:flex;align-items:center;gap:3px}' +
|
||
'.sl-lb{font-size:10px;color:#777;white-space:nowrap}' +
|
||
'.sl-val{font-size:10px;color:#9ab;white-space:nowrap;min-width:2.5em;text-align:right}' +
|
||
'.sl-sep{color:#444;padding:0 2px;font-size:11px;align-self:center}' +
|
||
'input[type=range]{-webkit-appearance:none;height:3px;background:#3a3a3a;border-radius:2px;cursor:pointer;width:72px;outline:none}' +
|
||
'input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;border-radius:50%;background:#5a9ab0;border:1px solid #7ac;cursor:pointer}' +
|
||
'input[type=range]::-moz-range-thumb{width:11px;height:11px;border-radius:50%;background:#5a9ab0;border:1px solid #7ac;cursor:pointer}' +
|
||
'.vol-ic{font-size:13px;cursor:pointer;color:#7ab;user-select:none;line-height:1}' +
|
||
'.row2{height:74px;display:flex;align-items:center;gap:6px;background:#0c0c0c}' +
|
||
'#smeter{height:64px;width:340px;flex-shrink:0;border:1px solid #333;border-radius:2px;display:block}' +
|
||
'.vfo-mid{flex:1;display:flex;align-items:center;justify-content:center;gap:5px}' +
|
||
'.vbtn{width:38px;height:38px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;cursor:pointer;border-radius:3px;color:#888;border:1px solid #444;background:linear-gradient(180deg,#2a2a2a,#1e1e1e);padding:0}' +
|
||
'.vbtn.active{color:#7fff7f;border-color:#3d7a3d;background:linear-gradient(180deg,#1e3e1e,#142814)}' +
|
||
'.vfo-ro{font:400 44px "Orbitron","Consolas",monospace;letter-spacing:0.02em;color:#7fff7f;border:none;border-radius:0;padding:0 6px;cursor:pointer;user-select:none;height:62px;display:inline-flex;align-items:baseline;padding-top:10px;white-space:nowrap;overflow:hidden;background:transparent}' +
|
||
'.vfo-ro.dim{color:#1e501e;background:transparent}' +
|
||
'.vfo-ro .dg{display:inline-block;min-width:0.55em;font-size:44px;line-height:1}' +
|
||
'.vfo-ro .dg.lead{color:#2a3a2a}.vfo-ro.dim .dg.lead{color:#162016}' +
|
||
'.vfo-ro .sp{display:inline-block;color:#2a6030;min-width:0.3em;font-size:44px;line-height:1}' +
|
||
'.vfo-ro.dim .sp{color:#142014}' +
|
||
'.vfo-ro .dg.sel{background:rgba(127,255,127,.18);outline:1px solid rgba(127,255,127,.35);border-radius:2px}' +
|
||
'.vfo-hz{display:inline-flex;align-items:baseline}' +
|
||
'.vfo-hz .dg{display:inline-block;font-size:22px;line-height:1;min-width:0.55em;font-weight:400}' +
|
||
'.vfo-ro.dim .vfo-hz .dg{color:#1e501e}' +
|
||
'.swap-g{display:flex;gap:3px}' +
|
||
'.swap-g button{padding:0 7px;height:22px;font-size:10px}' +
|
||
'.right-g{display:flex;align-items:center;gap:4px;margin-left:auto}' +
|
||
'.right-g button,.right-g select{height:62px;font-size:12px}' +
|
||
'.spec-area{flex:1 1 auto;min-height:80px;position:relative;background:#050a05;border:1px solid #252525;border-radius:3px;overflow:hidden}' +
|
||
'.spec-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
|
||
'.span-ov{position:absolute;top:4px;left:4px;display:flex;gap:3px;z-index:5}' +
|
||
'.sp-btn{background:rgba(15,25,15,0.78);color:#6a9a6a;border:1px solid rgba(60,100,60,0.5);border-radius:3px;padding:0 7px;font-size:10px;height:18px;cursor:pointer;backdrop-filter:blur(1px)}' +
|
||
'.sp-btn:hover{background:rgba(25,45,25,0.88);border-color:rgba(80,140,80,0.7);color:#9aca9a}' +
|
||
'.sp-btn.on{background:rgba(20,50,20,0.92);border-color:#4a9a4a;color:#aaffaa}' +
|
||
'.ruler-area{flex:0 0 24px;position:relative;background:#0c1210;border-left:1px solid #252525;border-right:1px solid #252525;overflow:hidden}' +
|
||
'.ruler-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
|
||
'.wf-area{flex:0 0 405px;position:relative;background:#000;border:1px solid #252525;border-radius:3px;overflow:hidden}' +
|
||
'.wf-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
|
||
'.sbar{flex:0 0 16px;display:flex;align-items:center;padding:0 6px;gap:10px;background:#0e0e0e;border-top:1px solid #222;font-size:10px;color:#555}' +
|
||
'#latEl{margin-left:auto}' +
|
||
'</style></head>' +
|
||
|
||
'<body><div class="wrap">' +
|
||
|
||
// Тулбар 1: громкость, AGC, режимы, DSP кнопки, START/STOP
|
||
'<div class="tbar">' +
|
||
'<span class="vol-ic" id="muteIc" title="Mute">🔊</span>' +
|
||
'<div class="sl-g"><input type="range" id="volSl" min="0" max="100" value="70"><span class="sl-val" id="volVal">70</span></div>' +
|
||
'<span class="sl-sep">|</span>' +
|
||
'<div class="sl-g"><span class="sl-lb">RF</span><input type="range" id="rfSl" min="0" max="120" value="90"><span class="sl-val" id="rfVal">90dB</span></div>' +
|
||
'<span class="sl-sep">|</span>' +
|
||
'<button id="toneBtn">TONE</button>' +
|
||
'<span class="sl-sep">|</span>' +
|
||
'<div class="sl-g"><span class="sl-lb">TX</span><input type="range" id="txSl" min="0" max="100" value="50"><span class="sl-val" id="txVal">50</span></div>' +
|
||
'<button id="moxBtn">MOX</button>' +
|
||
'<span class="sl-sep">|</span>' +
|
||
'<div class="tsep"></div>' +
|
||
'<select id="agcSel">' +
|
||
'<option value="0">AGC-F</option><option value="1" selected>AGC-M</option>' +
|
||
'<option value="2">AGC-S</option><option value="3">AGC-L</option><option value="4">AGC-OFF</option>' +
|
||
'</select>' +
|
||
'<select id="modGrpSel">' +
|
||
'<option value="ssb" selected>SSB</option><option value="cw">CW</option>' +
|
||
'<option value="fm">FM</option><option value="am">AM</option>' +
|
||
'</select>' +
|
||
'<select id="stepSel">' +
|
||
'<option value="10">10 Hz</option><option value="100" selected>100 Hz</option>' +
|
||
'<option value="1000">1 kHz</option><option value="10000">10 kHz</option>' +
|
||
'</select>' +
|
||
'<select id="attSel"><option value="0" selected>0 dB</option><option value="1">-10 dB</option><option value="2">-20 dB</option></select>' +
|
||
'<button id="nbBtn">NB</button>' +
|
||
'<button id="snbBtn">SNB</button>' +
|
||
'<button id="anfBtn">ANF</button>' +
|
||
'<button id="nrBtn">NR</button>' +
|
||
'<span class="sl-sep">|</span>' +
|
||
'<button id="startStopBtn" class="on">START</button>' +
|
||
'</div>' +
|
||
|
||
// Тулбар 2: S-метр, VFO A/B, диапазон, CW (кнопка TX/RX убрана)
|
||
'<div class="tbar row2">' +
|
||
'<canvas id="smeter" width="340" height="64"></canvas>' +
|
||
'<div class="vfo-mid">' +
|
||
'<button class="vbtn active" id="vBtnA">A</button>' +
|
||
'<div id="vDispA" class="vfo-ro"></div>' +
|
||
'<div class="swap-g">' +
|
||
'<button id="btnBA">B=A</button>' +
|
||
'<button id="btnSwap">SWAP</button>' +
|
||
'<button id="btnAB">A=B</button>' +
|
||
'</div>' +
|
||
'<span style="display:inline-block;width:12px"></span>' +
|
||
'<button class="vbtn" id="vBtnB">B</button>' +
|
||
'<div id="vDispB" class="vfo-ro dim"></div>' +
|
||
'</div>' +
|
||
'<div class="right-g">' +
|
||
'<button id="modTglBtn">LSB</button>' +
|
||
'<select id="bandSel"></select>' +
|
||
'<button id="fnpBtn">FNP</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
|
||
'<div class="spec-area"><canvas id="spec"></canvas><div class="span-ov" id="spanOv"></div></div>' +
|
||
'<div class="ruler-area"><canvas id="ruler"></canvas></div>' +
|
||
'<div class="wf-area"><canvas id="wf"></canvas></div>' +
|
||
'<div class="sbar"><span id="stEl">connecting...</span><span id="latEl"></span></div>' +
|
||
'</div>' +
|
||
|
||
'<script>' +
|
||
|
||
'const specCv=document.getElementById("spec"),wfCv=document.getElementById("wf"),' +
|
||
'rulerCv=document.getElementById("ruler"),smCv=document.getElementById("smeter");' +
|
||
'const sctx=specCv.getContext("2d"),wctx=wfCv.getContext("2d"),' +
|
||
'rctx=rulerCv.getContext("2d"),mctx=smCv.getContext("2d");' +
|
||
'const wfBuf=document.createElement("canvas"),wfBCtx=wfBuf.getContext("2d");' +
|
||
'const stEl=document.getElementById("stEl"),latEl=document.getElementById("latEl");' +
|
||
|
||
'let cur={},vfoA=14200000,vfoB=14200000,activeVfo="A";' +
|
||
'let localCenter=14200000,localSpan=192000;' + // локальные копии — обновляются мгновенно при действии пользователя
|
||
'let dragging=false,dragX0=0,dragCen0=0,dragVfo0=0,dragMs=0,wheelActiveTs=0;' +
|
||
'let hoverX=-1,wTarget="",markerOn=false,markerX=0;' +
|
||
'let specSm=[],wfAvg=[],wfHi=-50,wfLo=-120;' +
|
||
'let audioCtx=null,audioT=0;' +
|
||
|
||
'function audioKick(){if(!audioCtx){audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:48000});audioT=audioCtx.currentTime+0.05;}if(audioCtx.state!=="running")audioCtx.resume();}' +
|
||
|
||
'const PAL=[0x000000,0x000003,0x000006,0x000009,0x00000C,0x00000F,0x000012,0x000015,0x000019,0x00001C,0x00001F,0x000022,0x000025,0x000028,0x00002B,0x00002F,0x000032,0x000035,0x000038,0x00003B,0x00003E,0x000041,0x000045,0x000048,0x00004B,0x00004E,0x000051,0x000054,0x000057,0x00005A,0x00005E,0x000061,0x000064,0x000067,0x00006A,0x00006D,0x000070,0x000074,0x000077,0x00007A,0x00007D,0x000080,0x000083,0x000086,0x00008A,0x00008D,0x000090,0x000093,0x000096,0x000099,0x00009C,0x0000A0,0x0002A2,0x0004A4,0x0006A7,0x0008A9,0x000AAC,0x000CAE,0x000EB1,0x0010B3,0x0012B6,0x0014B8,0x0017BB,0x0019BD,0x001BC0,0x001DC2,0x001FC5,0x0021C7,0x0023CA,0x0025CC,0x0027CF,0x0029D1,0x002BD4,0x002ED6,0x0030D9,0x0032DB,0x0034DE,0x0036E0,0x0038E3,0x003AE5,0x003CE8,0x003EEA,0x0040EC,0x0042EF,0x0045F1,0x0047F4,0x0049F6,0x004BF9,0x004DFB,0x004FFE,0x0052FE,0x0056FD,0x005AFC,0x005DFB,0x0061FA,0x0065F9,0x0068F8,0x006CF7,0x0070F6,0x0073F6,0x0077F5,0x007BF4,0x007EF3,0x0082F2,0x0085F1,0x0089F0,0x008DEF,0x0090EE,0x0094ED,0x0098EC,0x009BEC,0x009FEB,0x00A3EA,0x00A6E9,0x00AAE8,0x00AEE7,0x00B1E6,0x00B5E5,0x00B9E4,0x00BCE3,0x00C0E2,0x00C4E1,0x00C7E1,0x00CBE0,0x00CFDF,0x00D2DE,0x00D6DD,0x00DADC,0x00DCD9,0x00DDD3,0x00DECD,0x00DFC7,0x00E0C2,0x00E1BC,0x00E1B6,0x00E2B0,0x00E3AB,0x00E4A5,0x00E59F,0x00E699,0x00E794,0x00E88E,0x00E98' +
|
||
'8,0x00EA82,0x00EB7D,0x00EC77,0x00EC71,0x00ED6B,0x00EE66,0x00EF60,0x00F05A,0x00F154,0x00F24F,0x00F349,0x00F443,0x00F53D,0x00F638,0x00F632,0x00F72C,0x00F826,0x00F921,0x00FA1B,0x00FB15,0x00FC0F,0x00FD0A,0x00FE04,0x01FF00,0x08FF00,0x0EFF00,0x15FF00,0x1CFF00,0x22FF00,0x29FF00,0x30FF00,0x36FF00,0x3DFF00,0x44FF00,0x4AFF00,0x51FF00,0x58FF00,0x5FFF00,0x65FF00,0x6CFF00,0x72FF00,0x79FF00,0x80FF00,0x86FF00,0x8DFF00,0x94FF00,0x9BFF00,0xA1FF00,0xA8FF00,0xAEFF00,0xB5FF00,0xBCFF00,0xC2FF00,0xC9FF00,0xD0FF00,0xD6FF00,0xDDFF00,0xE4FF00,0xEAFF00,0xF1FF00,0xF8FF00,0xFFFF00,0xFFF900,0xFFF300,0xFFED00,0xFFE800,0xFFE200,0xFFDC00,0xFFD600,0xFFD100,0xFFCB00,0xFFC500,0xFFC000,0xFFBA00,0xFFB400,0xFFAE00,0xFFA900,0xFFA300,0xFF9D00,0xFF9800,0xFF9200,0xFF8C00,0xFF8600,0xFF8100,0xFF7B00,0xFF7500,0xFF7000,0xFF6A00,0xFF6400,0xFF5E00,0xFF5900,0xFF5300,0xFF5304,0xFF5C11,0xFF641D,0xFF6D2A,0xFF7536,0xFF7E43,0xFF864F,0xFF8F5C,0xFF9868,0xFFA075,0xFFA982,0xFFB18E,0xFFBA9A,0xFFC2A7,0xFFCBB3,0xFFD4C0,0xFFDCCC,0xFFE5D9,0xFFEDE6,0xFFF6F2,0xFFFFFF];' +
|
||
|
||
'const cl=(v,a,b)=>Math.max(a,Math.min(b,v));' +
|
||
'const q=id=>document.getElementById(id);' +
|
||
'function sw(id,on){const b=q(id);if(b)b.classList.toggle("on",!!on);}' +
|
||
'function ws2(obj){if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}' +
|
||
'function cmd(c,v){ws2({cmd:c,on:v});}' +
|
||
'function updNrBtn(){const b=q("nrBtn");if(!b)return;const m=cur.nr_mode|0;b.textContent=(m===2)?"NR2":(m===3)?"NR3":(m===4)?"NR4":"NR";sw("nrBtn",m!==0);}' +
|
||
'function updNbBtn(){const b=q("nbBtn");if(!b)return;const m=cur.nb_mode|0;b.textContent=(m===2)?"NB2":"NB";sw("nbBtn",m!==0);}' +
|
||
'function updSnbBtn(){sw("snbBtn",!!cur.snb);}' +
|
||
'function updAnfBtn(){sw("anfBtn",!!cur.anf);}' +
|
||
|
||
'const MODE_N=["LSB","USB","DSB","CWL","CWU","FM","AM","SAM"];' +
|
||
'const GRP_MAP={ssb:[0,1],cw:[3,4],fm:[5,5],am:[6,7]};' +
|
||
'const MODE_TO_GRP=["ssb","ssb","ssb","cw","cw","fm","am","am"];' +
|
||
'function modGrpFromMode(m){return MODE_TO_GRP[m]||"ssb";}' +
|
||
'function modGrp(){return q("modGrpSel").value||"ssb";}' +
|
||
'function modTglTarget(){const grp=GRP_MAP[modGrp()];const m=cur.mode||0;return(m===grp[0])?grp[1]:grp[0];}' +
|
||
'function updModTglLabel(){const b=q("modTglBtn");if(!b)return;const grp=GRP_MAP[modGrp()];const m=cur.mode||0;const show=(m===grp[0]||m===grp[1])?m:grp[0];b.textContent=MODE_N[show];}' +
|
||
'function syncModGrp(m){const g=MODE_TO_GRP[m]||"ssb";const s=q("modGrpSel");if(s&&s.value!==g)s.value=g;updModTglLabel();}' +
|
||
'q("modGrpSel").onchange=function(){const grp=GRP_MAP[modGrp()];const defaultMode=grp[0];cur.mode=defaultMode;updModTglLabel();pend("mode");ws2({cmd:"mode",mode:defaultMode});};' +
|
||
'q("modTglBtn").onclick=function(){audioKick();const next=modTglTarget();cur.mode=next;updModTglLabel();pend("mode");ws2({cmd:"mode",mode:next});};' +
|
||
'q("agcSel").onchange=function(e){pend("agc");ws2({cmd:"agc",mode:+e.target.value});};' +
|
||
'q("attSel").onchange=function(e){pend("attn");ws2({cmd:"attn",idx:+e.target.value});};' +
|
||
|
||
// FNP — фильтровый оверлей
|
||
'var FNP_N={ssb:["5.0k","4.4k","3.8k","3.3k","2.9k","2.7k","2.4k","2.1k","1.8k","1.0k"],' +
|
||
'cw:["1.0k","800","750","600","500","400","250","100","50","25"],' +
|
||
'am:["16k","12k","10k","8k","6.6k","5.2k","4.0k","3.1k","2.9k","2.4k"],' +
|
||
'fm:["20k","15k","12k","10k","8k","6k","5k","4k","3k","2k"]};' +
|
||
'var FNP_BW={ssb:[5000,4400,3800,3300,2900,2700,2400,2100,1800,1000],' +
|
||
'cw:[1000,800,750,600,500,400,250,100,50,25],' +
|
||
'am:[16000,12000,10000,8000,6600,5200,4000,3100,2900,2400],' +
|
||
'fm:[20000,15000,12000,10000,8000,6000,5000,4000,3000,2000]};' +
|
||
'var fnpOv=null;' +
|
||
'function fnpGrp(){var m=cur.mode|0;return(m===3||m===4)?"cw":(m===5)?"fm":(m===2||m===6||m===7)?"am":"ssb";}' +
|
||
'function closeFnp(){if(fnpOv){fnpOv.remove();fnpOv=null;}document.removeEventListener("click",fnpOutside);}' +
|
||
'function fnpOutside(e){if(fnpOv&&!fnpOv.contains(e.target)&&e.target!==q("fnpBtn"))closeFnp();}' +
|
||
'function showFnp(){' +
|
||
'if(fnpOv){closeFnp();return;}' +
|
||
'var g=fnpGrp(),ns=FNP_N[g],bws=FNP_BW[g],cb=cur.filter_bw||2700;' +
|
||
'fnpOv=document.createElement("div");' +
|
||
'fnpOv.style.cssText="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
|
||
'background:rgba(6,14,6,0.93);border:1px solid rgba(70,120,70,0.65);border-radius:8px;' +
|
||
'padding:12px 14px;z-index:2000;backdrop-filter:blur(6px);box-shadow:0 4px 24px rgba(0,0,0,0.7);min-width:260px;";' +
|
||
// заголовок
|
||
'var hd=document.createElement("div");' +
|
||
'hd.style.cssText="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;";' +
|
||
'var tl=document.createElement("span");' +
|
||
'tl.textContent="Filter — "+g.toUpperCase();' +
|
||
'tl.style.cssText="color:#80b880;font-family:monospace;font-size:12px;letter-spacing:1px;";' +
|
||
'var xb=document.createElement("button");' +
|
||
'xb.textContent="×";' +
|
||
'xb.style.cssText="background:none;border:none;color:#6a9a6a;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;";' +
|
||
'xb.onclick=closeFnp;' +
|
||
'hd.appendChild(tl);hd.appendChild(xb);fnpOv.appendChild(hd);' +
|
||
// сетка кнопок
|
||
'var gd=document.createElement("div");' +
|
||
'gd.style.cssText="display:grid;grid-template-columns:repeat(5,1fr);gap:5px;";' +
|
||
'ns.forEach(function(n,i){' +
|
||
'var b=document.createElement("button"),act=bws[i]===cb;' +
|
||
'b.textContent=n;' +
|
||
'b.style.cssText="background:"+(act?"rgba(25,70,25,0.95)":"rgba(12,28,12,0.85)")+";' +
|
||
'color:"+(act?"#b0ffb0":"#7aaa7a")+";border:1px solid "+(act?"rgba(120,220,120,0.7)":"rgba(55,95,55,0.45)")+";' +
|
||
'border-radius:4px;padding:5px 0;font-family:monospace;font-size:12px;cursor:pointer;' +
|
||
'transition:background 0.1s;";' +
|
||
'b.onmouseenter=function(){if(!act)this.style.background="rgba(20,50,20,0.95)";}; ' +
|
||
'b.onmouseleave=function(){if(!act)this.style.background="rgba(12,28,12,0.85)";}; ' +
|
||
'b.onclick=function(){cur.filter_bw=bws[i];pend("filter");ws2({cmd:"filter",bw:bws[i]});closeFnp();};' +
|
||
'gd.appendChild(b);' +
|
||
'});' +
|
||
'fnpOv.appendChild(gd);' +
|
||
'document.body.appendChild(fnpOv);' +
|
||
'setTimeout(function(){document.addEventListener("click",fnpOutside);},0);' +
|
||
'}' +
|
||
'q("fnpBtn").onclick=showFnp;' +
|
||
|
||
'q("volSl").oninput=function(e){ws2({cmd:"volume",v:+e.target.value});var v=q("volVal");if(v)v.textContent=e.target.value;};' +
|
||
'q("rfSl").oninput=function(e){ws2({cmd:"agctop",db:+e.target.value});var v=q("rfVal");if(v)v.textContent=e.target.value+"dB";};' +
|
||
'q("txSl").oninput=function(e){var v=+e.target.value;var lbl=q("txVal");if(lbl)lbl.textContent=v;pend("drive");ws2({cmd:"drive",v:v});};' +
|
||
'function updMoxBtn(tx){var b=q("moxBtn");if(!b)return;b.textContent=tx?"TX":"MOX";b.classList.toggle("mox-on",!!tx);}' +
|
||
'q("moxBtn").onclick=function(){audioKick();cur.transmitting=!cur.transmitting;pend("mox");updMoxBtn(cur.transmitting);cmd("set_mox",cur.transmitting);};' +
|
||
'function updToneBtn(t){var b=q("toneBtn");if(!b)return;b.classList.toggle("on",!!t);}' +
|
||
'q("toneBtn").onclick=function(){audioKick();cur.tuning=!cur.tuning;pend("tun");updToneBtn(cur.tuning);cmd("set_tun",cur.tuning);};' +
|
||
|
||
// DSP кнопки — cur обновляется локально сразу, не ждём ответа сервера
|
||
'q("nrBtn").onclick=function(){cur.nr_mode=((cur.nr_mode|0)+1)%5;cur.nr=cur.nr_mode!==0;updNrBtn();pend("nr");ws2({cmd:"set_nr_mode",mode:cur.nr_mode});};' +
|
||
'q("nbBtn").onclick=function(){cur.nb_mode=((cur.nb_mode|0)+1)%3;cur.nb=cur.nb_mode!==0;updNbBtn();pend("nb");ws2({cmd:"set_nb_mode",mode:cur.nb_mode});};' +
|
||
'q("snbBtn").onclick=function(){cur.snb=!cur.snb;updSnbBtn();pend("snb");cmd("set_snb",cur.snb);};' +
|
||
'q("anfBtn").onclick=function(){cur.anf=!cur.anf;updAnfBtn();pend("anf");cmd("set_anf",cur.anf);};' +
|
||
|
||
// START/STOP
|
||
'function updRunBtn(r){var b=q("startStopBtn");if(!b)return;b.textContent=r?"STOP":"START";b.classList.toggle("rx-tx",!!r);b.classList.toggle("on",!r);}' +
|
||
'q("startStopBtn").onclick=function(){audioKick();cur.running=!cur.running;pend("run");updRunBtn(cur.running);cmd("set_run",cur.running);};' +
|
||
|
||
'q("muteIc").onclick=()=>{cur.mute=!cur.mute;q("muteIc").textContent=cur.mute?"\uD83D\uDD07":"\uD83D\uDD0A";cmd("set_mute",cur.mute);};' +
|
||
|
||
'let stA=0,stAmem=100,stB=0,stBmem=100;' +
|
||
'function fmtVfo(hz,step){' +
|
||
'const s=String(Math.max(0,Math.round(hz||0))).padStart(9,"0");' +
|
||
'let h="",seenNZ=false;' +
|
||
'for(let i=0;i<6;i++){' +
|
||
'const st=Math.pow(10,8-i);' +
|
||
'if(s[i]!=="0")seenNZ=true;' +
|
||
'const lead=(!seenNZ)?" lead":"";' +
|
||
'const sel=(step>0&&st===step)?" sel":"";' +
|
||
'h+="<span class=\"dg"+lead+sel+" data-s=\""+st+"\">"+s[i]+"</span>";' +
|
||
'if(i===2)h+="<span class=\"sp\">.</span>";}' +
|
||
'h+="<span class=\"sp\">.</span>";' +
|
||
'h+="<span class=\"vfo-hz\">";' +
|
||
'for(let i=6;i<9;i++){const st=Math.pow(10,8-i);' +
|
||
'const sel=(step>0&&st===step)?" sel":"";' +
|
||
'h+="<span class=\"dg"+sel+" data-s=\""+st+"\">"+s[i]+"</span>";}' +
|
||
'return h;}' +
|
||
'function renderVfos(){q("vDispA").innerHTML=fmtVfo(vfoA,stA);q("vDispB").innerHTML=fmtVfo(vfoB,stB);}' +
|
||
|
||
'function bindVfo(dispId,getHz,setHz,getSt,setSt,getMem,setMem,onSend){' +
|
||
'const el=q(dispId);' +
|
||
'el.addEventListener("mousemove",e=>{const t=e.target,s=Number(t?.dataset?.s||0);if(s>0){setSt(s);setMem(s);renderVfos();}});' +
|
||
'el.addEventListener("click",e=>{const t=e.target,s=Number(t?.dataset?.s||0);if(s>0){setSt(s);setMem(s);}});' +
|
||
'el.addEventListener("mouseleave",()=>{if(getSt()!==0){setSt(0);renderVfos();}});' +
|
||
'el.addEventListener("wheel",e=>{e.preventDefault();e.stopPropagation();audioKick();' +
|
||
'const dir=e.deltaY<0?1:-1,st=getSt()||getMem()||100;' +
|
||
'const nf=cl(Math.round(getHz())+st*dir,30000,60000000);' +
|
||
'setHz(nf);onSend(nf);},{passive:false});' +
|
||
'el.addEventListener("dblclick",()=>{' +
|
||
'const s=prompt("Set Hz",String(Math.round(getHz())));if(!s)return;' +
|
||
'const f=parseInt(s.replace(/[^0-9]/g,""),10);if(!isFinite(f))return;' +
|
||
'const nf=cl(f,30000,60000000);setHz(nf);renderVfos();onSend(nf);' +
|
||
'});}' +
|
||
'bindVfo("vDispA",()=>vfoA,v=>{vfoA=v;},()=>stA,v=>{stA=v;},()=>stAmem,v=>{stAmem=v;},' +
|
||
'function(nf){renderVfos();pend("freq");if(activeVfo==="A"){localCenter=nf;ws2({cmd:"freq",hz:nf});}else ws2({cmd:"freq_b",hz:nf});});' +
|
||
'bindVfo("vDispB",()=>vfoB,v=>{vfoB=v;},()=>stB,v=>{stB=v;},()=>stBmem,v=>{stBmem=v;},' +
|
||
'function(nf){renderVfos();pend("freq");if(activeVfo==="B"){localCenter=nf;ws2({cmd:"freq",hz:nf});}else ws2({cmd:"freq_b",hz:nf});});' +
|
||
|
||
'function selVfo(w){' +
|
||
'activeVfo=w;' +
|
||
'q("vBtnA").classList.toggle("active",w==="A");' +
|
||
'q("vBtnB").classList.toggle("active",w==="B");' +
|
||
'q("vDispA").classList.toggle("dim",w!=="A");' +
|
||
'q("vDispB").classList.toggle("dim",w!=="B");' +
|
||
// Notify server: switch active VFO (server calls ActivateVfo which retunes DDC)
|
||
'ws2({cmd:"set_active_vfo",idx:w==="A"?0:1});' +
|
||
'}' +
|
||
'q("vBtnA").onclick=()=>{if(activeVfo!=="A")selVfo("A");};' +
|
||
'q("vBtnB").onclick=()=>{if(activeVfo!=="B")selVfo("B");};' +
|
||
// B=A: copy A freq into B
|
||
'q("btnBA").onclick=()=>{vfoB=vfoA;renderVfos();pend("freq");ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
|
||
// A=B: copy B freq into A — always freq_a (sets VFO A regardless of active VFO)
|
||
'q("btnAB").onclick=()=>{vfoA=vfoB;renderVfos();pend("freq");ws2({cmd:"freq_a",hz:Math.round(vfoA)});};' +
|
||
// SWAP — freq_a always sets VFO A, freq_b always sets VFO B
|
||
'q("btnSwap").onclick=()=>{const t=vfoA;vfoA=vfoB;vfoB=t;renderVfos();pend("freq");' +
|
||
'ws2({cmd:"freq_a",hz:Math.round(vfoA)});ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
|
||
|
||
// SPAN кнопки: только 48K..1536K
|
||
'const SPANS=[48000,96000,192000,384000,768000,1536000];' +
|
||
'const SPAN_L=["48K","96K","192K","384K","768K","1536K"];' +
|
||
'(function(){const c=q("spanOv");SPANS.forEach((hz,i)=>{const b=document.createElement("button");b.className="sp-btn";b.id="sb"+i;b.textContent=SPAN_L[i];b.onclick=()=>ws2({cmd:"span",hz});c.appendChild(b);});})();' +
|
||
'function updSpan(s){SPANS.forEach((hz,i)=>{const b=q("sb"+i);if(b)b.classList.toggle("on",hz===s);});}' +
|
||
|
||
'const BAND_N=["160m","80m","60m","40m","30m","20m","17m","15m","12m","10m","6m"];' +
|
||
'const BAND_F=[1900000,3750000,5357000,7100000,10125000,14200000,18120000,21200000,24940000,28500000,50150000];' +
|
||
'(function(){const s=q("bandSel");BAND_N.forEach((n,i)=>{const o=document.createElement("option");o.value=i;o.textContent=n;s.appendChild(o);});s.onchange=()=>ws2({cmd:"band",idx:+s.value});})();' +
|
||
'function updBand(vfo){let bi=0,bd=1e15;BAND_F.forEach((f,i)=>{const d=Math.abs((vfo||0)-f);if(d<bd){bd=d;bi=i;}});q("bandSel").value=bi;}' +
|
||
|
||
'let smAvg=-130,smPeak=-130,smMin=-130;' +
|
||
'function dbToX(db,bx,bw){const DB_MIN=-127,DB_MAX=-13;return Math.max(bx,Math.min(bx+bw,Math.round(bx+(db-DB_MIN)/(DB_MAX-DB_MIN)*bw)));}' +
|
||
'function paintSM(dbm){' +
|
||
'const w=smCv.clientWidth||340,h=smCv.clientHeight||64;' +
|
||
'const AVG_ALPHA=0.40,ZONE_DB=6,PEAK_BASE=0.12,MIN_BASE=0.12,ACCEL=0.030,MAX_A=0.92;' +
|
||
'const DBM_MARKS=[-120,-100,-80,-60,-40,-20];' +
|
||
'const DBM_LABELS=["-120","-100","-80","-60","-40","-20"];' +
|
||
'const S_MARKS_DBM=[-121,-109,-97,-85,-73,-53,-33,-13];' +
|
||
'const S_MARKS_LBL=["S1","S3","S5","S7","S9","+20","+40","+60"];' +
|
||
'const TICK_LONG=5,TICK_MED=3;' +
|
||
'smAvg=smAvg*(1-AVG_ALPHA)+dbm*AVG_ALPHA;' +
|
||
'const pTarget=Math.max(dbm,smAvg+ZONE_DB),mTarget=Math.min(dbm,smAvg-ZONE_DB);' +
|
||
'const pA=Math.min(MAX_A,PEAK_BASE+Math.abs(pTarget-smPeak)*ACCEL);' +
|
||
'const mA=Math.min(MAX_A,MIN_BASE+Math.abs(mTarget-smMin)*ACCEL);' +
|
||
'smPeak+=pA*(pTarget-smPeak);smMin+=mA*(mTarget-smMin);' +
|
||
'const BX=62,BW=Math.max(10,w-66);' +
|
||
'const YT=Math.round(h*36/100),YB=Math.round(h*66/100);' +
|
||
'const S9X=dbToX(-73,BX,BW),OVRX=dbToX(-43,BX,BW);' +
|
||
'mctx.fillStyle="#0c0c0c";mctx.fillRect(0,0,w,h);' +
|
||
'mctx.fillStyle="#061006";mctx.fillRect(BX,YT,S9X-BX,YB-YT);' +
|
||
'mctx.fillStyle="#0A1006";mctx.fillRect(S9X,YT,OVRX-S9X,YB-YT);' +
|
||
'mctx.fillStyle="#060A10";mctx.fillRect(OVRX,YT,BX+BW-OVRX,YB-YT);' +
|
||
'const barEnd=dbToX(dbm,BX,BW);' +
|
||
'if(barEnd>BX){mctx.fillStyle="#091F00";mctx.fillRect(BX,YT+1,Math.min(barEnd,S9X)-BX,YB-YT-2);' +
|
||
'if(barEnd>S9X){mctx.fillStyle="#091528";mctx.fillRect(S9X,YT+1,barEnd-S9X,YB-YT-2);}}' +
|
||
'const px1=Math.max(BX+1,Math.min(dbToX(smMin,BX,BW),dbToX(smPeak,BX,BW)));' +
|
||
'const px2=Math.min(BX+BW-1,Math.max(dbToX(smMin,BX,BW),dbToX(smPeak,BX,BW)));' +
|
||
'if(px2>px1){mctx.fillStyle="rgba(255,255,255,0.25)";mctx.fillRect(px1,YT+1,px2-px1,YB-YT-2);}' +
|
||
'const pX=dbToX(smPeak,BX,BW);' +
|
||
'mctx.strokeStyle="#F8F8FF";mctx.lineWidth=2;mctx.beginPath();mctx.moveTo(pX,YT);mctx.lineTo(pX,YB);mctx.stroke();' +
|
||
'mctx.lineWidth=1;mctx.strokeStyle="#303028";mctx.strokeRect(BX,YT,BW,YB-YT);' +
|
||
'mctx.font="10px Courier New";' +
|
||
'for(let i=0;i<DBM_MARKS.length;i++){const x=dbToX(DBM_MARKS[i],BX,BW);' +
|
||
'mctx.strokeStyle="#DCE0E0";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YT-TICK_LONG);mctx.lineTo(x,YT-1);mctx.stroke();' +
|
||
'const t=DBM_LABELS[i],tw=mctx.measureText(t).width;' +
|
||
'mctx.fillStyle="#DCDCD0";mctx.fillText(t,Math.round(x-tw/2),YT-TICK_LONG-1);}' +
|
||
'for(let d=-125;d<-13;d+=5){const x=dbToX(d,BX,BW);' +
|
||
'mctx.strokeStyle="#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YT-TICK_MED);mctx.lineTo(x,YT-1);mctx.stroke();}' +
|
||
'for(let i=0;i<S_MARKS_DBM.length;i++){const x=dbToX(S_MARKS_DBM[i],BX,BW),ov=S_MARKS_DBM[i]>=-73;' +
|
||
'mctx.strokeStyle=ov?"#70A090":"#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YB+1);mctx.lineTo(x,YB+TICK_LONG);mctx.stroke();' +
|
||
'const t=S_MARKS_LBL[i],tw=mctx.measureText(t).width;' +
|
||
'mctx.fillStyle=ov?"#90D090":"#E0E0E0";mctx.fillText(t,Math.round(x-tw/2),YB+TICK_LONG+9);}' +
|
||
'mctx.fillStyle="#DCD896";mctx.font="bold 12px Courier New";' +
|
||
'mctx.fillText(dbm.toFixed(1).padStart(6," "),1,12);' +
|
||
'mctx.fillStyle="#707050";mctx.font="10px Courier New";mctx.fillText("dBm",1,YT-2);' +
|
||
'let sLbl;' +
|
||
'if(dbm>=-73){let over=Math.round(dbm+73);if(over<5)sLbl="S9";else{over=Math.round((over+5)/10)*10;if(over===0)over=10;sLbl="S9+"+over;}}' +
|
||
'else if(dbm<=-121){sLbl="S1";}' +
|
||
'else{sLbl="S1";for(let i=1;i<=8;i++){if(dbm>=(-121+(i-1)*6))sLbl="S"+i;}}' +
|
||
'mctx.fillStyle="#F0F0F0";mctx.font="bold 12px Courier New";mctx.fillText(sLbl,1,YB+TICK_LONG+10);}' +
|
||
|
||
// pendingCmds: команды в пути — не перезаписываем эти поля из server state
|
||
'var pendingCmds={};' +
|
||
'function pend(field){pendingCmds[field]=Date.now()+600;}' +
|
||
'function isPending(field){return !!(pendingCmds[field]&&pendingCmds[field]>Date.now());}' +
|
||
|
||
// Маппинг: server JSON field → pend key
|
||
'var PEND_MAP={vfo_a_hz:"freq",vfo_b_hz:"freq",nr:"nr",nr_mode:"nr",nb:"nb",nb_mode:"nb",snb:"snb",anf:"anf",running:"run",mode:"mode",agc_mode:"agc",transmitting:"mox",drive:"drive",attn_idx:"attn",tuning:"tun",filter_bw:"filter"};' +
|
||
|
||
'function applyState(msg){' +
|
||
'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' +
|
||
// Обновляем localCenter/localSpan из сервера только когда пользователь не тянет
|
||
'if(!dragging&&Date.now()-wheelActiveTs>300){' +
|
||
// В CTUN без DUP: TX-спектр WDSP центрирован по vfo_a_hz, а не по DDC LO.
|
||
// В CTUN с DUP: показываем RX-спектр → center_hz остаётся.
|
||
'if(msg.center_hz!=null)localCenter=(cur.transmitting&&cur.ctun&&!cur.dup)?cur.vfo_a_hz:msg.center_hz;' +
|
||
'if(msg.span_hz!=null)localSpan=msg.span_hz||192000;' +
|
||
'}' +
|
||
'if(!isPending("freq")){vfoA=cur.vfo_a_hz||vfoA;}' +
|
||
'if(msg.vfo_b_hz!=null)vfoB=msg.vfo_b_hz;' +
|
||
'if(msg.active_vfo!=null&&!isPending("freq")){' +
|
||
'const sv=msg.active_vfo===0?"A":"B";' +
|
||
'if(sv!==activeVfo){activeVfo=sv;' +
|
||
'q("vBtnA").classList.toggle("active",activeVfo==="A");' +
|
||
'q("vBtnB").classList.toggle("active",activeVfo==="B");' +
|
||
'q("vDispA").classList.toggle("dim",activeVfo!=="A");' +
|
||
'q("vDispB").classList.toggle("dim",activeVfo!=="B");}}' +
|
||
'renderVfos();' +
|
||
'const as=q("agcSel");if(as&&cur.agc_mode!=null&&!isPending("agc"))as.value=cur.agc_mode;' +
|
||
'const vs=q("volSl");if(vs&&!vs.matches(":active")){vs.value=cur.volume||70;var vv=q("volVal");if(vv)vv.textContent=cur.volume||70;}' +
|
||
'const rs=q("rfSl");if(rs&&!rs.matches(":active")){rs.value=cur.agc_top||90;var rv=q("rfVal");if(rv)rv.textContent=(cur.agc_top||90)+"dB";}' +
|
||
'if(!isPending("nr"))updNrBtn();if(!isPending("nb"))updNbBtn();' +
|
||
'if(!isPending("snb"))updSnbBtn();if(!isPending("anf"))updAnfBtn();' +
|
||
'if(!isPending("run"))updRunBtn(!!cur.running);' +
|
||
'if(!isPending("mox"))updMoxBtn(!!cur.transmitting);' +
|
||
'if(!isPending("tun"))updToneBtn(!!cur.tuning);' +
|
||
'if(!isPending("drive")){const ts=q("txSl");if(ts&&!ts.matches(":active")){ts.value=cur.drive||0;var tv=q("txVal");if(tv)tv.textContent=cur.drive||0;}}' +
|
||
'if(!isPending("mode")){syncModGrp(cur.mode||0);}' +
|
||
'if(!isPending("attn")){const at=q("attSel");if(at)at.value=cur.attn_idx||0;}' +
|
||
'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' +
|
||
'paintSM(Number(cur.smeter_dbm||-130));' +
|
||
'const mn=cur.mode_name||(MODE_N[cur.mode||0]||"?");' +
|
||
'stEl.textContent=(cur.connected?"OK":"OFF")+" | "+(cur.running?"RUN":"STOP")+" | "+(cur.transmitting?"TX":"RX")+" | "+mn+" | "+((activeVfoHz()/1e6).toFixed(3))+" MHz";}' +
|
||
|
||
'function pal(db){const v=Math.round(cl((db-wfLo)/Math.max(1,wfHi-wfLo),0,1)*255);const c=PAL[v]|0;return[(c>>16)&255,(c>>8)&255,c&255];}' +
|
||
'function fltLH(){const bw=cur.filter_bw||2700;const m=cur.mode|0;if(m===0)return[-bw,-100];if(m===1)return[100,bw];return[-bw/2,bw/2];}' +
|
||
'function xToHz(x,w){return localCenter+((x/Math.max(1,w))-.5)*localSpan;}' +
|
||
'function hzToX(f,w){return((f-localCenter)/localSpan+.5)*w;}' +
|
||
|
||
'function drawMk(ctx,w,h){if(!markerOn)return;ctx.save();ctx.strokeStyle="rgba(255,80,80,.85)";ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(markerX,0);ctx.lineTo(markerX,h);ctx.stroke();' +
|
||
'const lbl=((xToHz(markerX,w))/1e6).toFixed(4)+" M";ctx.fillStyle="rgba(35,5,5,.82)";ctx.fillRect(cl(markerX+5,0,w-88),h-16,84,13);ctx.fillStyle="#f88";ctx.font="10px monospace";ctx.fillText(lbl,cl(markerX+8,2,w-86),h-6);ctx.restore();}' +
|
||
|
||
'function drawRuler(){const w=rulerCv.clientWidth,h=rulerCv.clientHeight;rctx.fillStyle="#0c1210";rctx.fillRect(0,0,w,h);' +
|
||
'const st=localCenter-localSpan/2;' +
|
||
'for(let i=0;i<=8;i++){const x=i*w/8,f=st+i*localSpan/8;rctx.strokeStyle="#2a3a2e";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(x+.5,0);rctx.lineTo(x+.5,7);rctx.stroke();rctx.fillStyle="#7a9a80";rctx.font="10px monospace";const t=(f/1e6).toFixed(3);rctx.fillText(t,x-14,21);}' +
|
||
'const vxr=hzToX(activeVfoHz(),w);rctx.strokeStyle="#60d070";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(vxr+.5,0);rctx.lineTo(vxr+.5,h);rctx.stroke();}' +
|
||
|
||
'function drawSpec(bins){const w=specCv.clientWidth,h=specCv.clientHeight;sctx.fillStyle="#050a05";sctx.fillRect(0,0,w,h);' +
|
||
'for(let i=1;i<5;i++){const y=i*h/5;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(0,y);sctx.lineTo(w,y);sctx.stroke();}' +
|
||
'if(!bins?.length){drawMk(sctx,w,h);return;}' +
|
||
'if(specSm.length!==bins.length)specSm=new Array(bins.length).fill(-130);' +
|
||
'const avf=activeVfoHz();const[fl,fh]=fltLH(),x1=hzToX(avf+fl,w),x2=hzToX(avf+fh,w);' +
|
||
'sctx.fillStyle="rgba(80,150,255,.12)";sctx.fillRect(Math.min(x1,x2),0,Math.abs(x2-x1),h);' +
|
||
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));specSm[i]=specSm[i]*.70+bins[i]*.30;const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
|
||
'const g=sctx.createLinearGradient(0,0,0,h);g.addColorStop(0,"rgba(60,230,60,.2)");g.addColorStop(1,"rgba(60,230,60,.01)");' +
|
||
'sctx.lineTo(w,h);sctx.lineTo(0,h);sctx.closePath();sctx.fillStyle=g;sctx.fill();' +
|
||
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
|
||
'sctx.strokeStyle="#3ddd3d";sctx.lineWidth=1.2;sctx.stroke();' +
|
||
'const vx=hzToX(avf,w);sctx.strokeStyle="#60ff80";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(vx,0);sctx.lineTo(vx,h);sctx.stroke();drawMk(sctx,w,h);}' +
|
||
|
||
'function drawWf(bins){const w=wfCv.clientWidth,h=wfCv.clientHeight;' +
|
||
'if(!bins?.length){drawMk(wctx,w,h);return;}' +
|
||
'if(wfBuf.width!==w||wfBuf.height!==h){wfBuf.width=w;wfBuf.height=h;wfBCtx.fillStyle="#000";wfBCtx.fillRect(0,0,w,h);}' +
|
||
'if(wfAvg.length!==bins.length){wfAvg=new Array(bins.length).fill(-120);wfHi=-50;wfLo=-120;}' +
|
||
'const useAgc=cur.wf_agc!==false;const useNf=cur.wf_nf!==false;' +
|
||
'for(let i=0;i<bins.length;i++){wfAvg[i]=wfAvg[i]*.75+bins[i]*.25;}' +
|
||
'let peak=-200,nfSum=0,nfCount=0;' +
|
||
'for(let i=0;i<bins.length;i++){if(wfAvg[i]>peak)peak=wfAvg[i];}' +
|
||
'for(let i=0;i<bins.length;i++){if(wfAvg[i]<wfLo+15){nfSum+=wfAvg[i];nfCount++;}}' +
|
||
'if(peak>wfHi)wfHi=peak+5;else wfHi=wfHi+0.02*((peak+5)-wfHi);' +
|
||
'if(nfCount>10)wfLo=wfLo+0.005*(((nfSum/nfCount)-5)-wfLo);' +
|
||
'let hHi=wfHi,hLo=wfLo;if(hHi<hLo+15)hHi=hLo+15;' +
|
||
'if(hHi>0)hHi=0;if(hLo<-160)hLo=-160;wfHi=hHi;wfLo=hLo;' +
|
||
'wfBCtx.drawImage(wfBuf,0,0,w,h-1,0,1,w,h-1);const row=wfBCtx.createImageData(w,1);' +
|
||
'for(let x=0;x<w;x++){const i=Math.floor(x*(wfAvg.length-1)/Math.max(1,w-1));const[r,g,b]=pal(wfAvg[i]);const o=x*4;row.data[o]=r;row.data[o+1]=g;row.data[o+2]=b;row.data[o+3]=255;}' +
|
||
'wfBCtx.putImageData(row,0,0);wctx.drawImage(wfBuf,0,0,w,h);' +
|
||
'const avf2=activeVfoHz();const[fl2,fh2]=fltLH(),x1w=hzToX(avf2+fl2,w),x2w=hzToX(avf2+fh2,w);' +
|
||
'wctx.fillStyle="rgba(220,230,230,.10)";wctx.fillRect(Math.min(x1w,x2w),0,Math.abs(x2w-x1w),h);' +
|
||
'const vxw=hzToX(avf2,w);wctx.strokeStyle="#60ff80";wctx.lineWidth=1;wctx.beginPath();wctx.moveTo(vxw,0);wctx.lineTo(vxw,h);wctx.stroke();drawMk(wctx,w,h);}' +
|
||
|
||
'function rcv(cv){const dpr=Math.max(1,devicePixelRatio||1),w=cv.clientWidth|0,h=cv.clientHeight|0;if(cv.width!==(w*dpr|0)||cv.height!==(h*dpr|0)){cv.width=w*dpr;cv.height=h*dpr;cv.getContext("2d").setTransform(dpr,0,0,dpr,0,0);}}' +
|
||
'function resize(){rcv(specCv);rcv(wfCv);rcv(rulerCv);const sw=smCv.clientWidth|0,sh=smCv.clientHeight|0;if(smCv.width!==sw||smCv.height!==sh){smCv.width=sw;smCv.height=sh;}if(wfBuf.width!==wfCv.clientWidth||wfBuf.height!==wfCv.clientHeight){wfBuf.width=wfCv.clientWidth;wfBuf.height=wfCv.clientHeight;wfBCtx.fillStyle="#000";wfBCtx.fillRect(0,0,wfBuf.width,wfBuf.height);}}' +
|
||
|
||
'function tuneStep(e){const s=+q("stepSel").value||100;if(e.ctrlKey&&e.shiftKey)return 1000000;if(e.ctrlKey)return 1000;if(e.shiftKey)return 10;return s;}' +
|
||
'function activeVfoHz(){return activeVfo==="A"?vfoA:vfoB;}' +
|
||
'function setActiveVfoHz(f){if(activeVfo==="A")vfoA=f;else vfoB=f;}' +
|
||
'function sendActiveFreq(f){if(activeVfo==="A")ws2({cmd:"freq",hz:f});else ws2({cmd:"freq",hz:f});}' +
|
||
'function doWheel(e){e.preventDefault();e.stopPropagation();audioKick();const dir=e.deltaY<0?1:-1,st=tuneStep(e),base=Math.floor(activeVfoHz()/st)*st;' +
|
||
'const nf=cl(dir>0?base+st:(Math.round(activeVfoHz())===base?base-st:base),30000,60000000);' +
|
||
'setActiveVfoHz(nf);wheelActiveTs=Date.now();' +
|
||
'if(activeVfo==="A"){cur.vfo_a_hz=nf;}else{cur.vfo_b_hz=nf;}' +
|
||
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});}' +
|
||
'function bindCv(cv){let wT=0;const wh=e=>{const n=performance.now();if(n-wT<20)return;wT=n;wTarget=cv.id;doWheel(e);};' +
|
||
'cv.addEventListener("contextmenu",e=>e.preventDefault());' +
|
||
'cv.addEventListener("mouseenter",()=>{wTarget=cv.id;});' +
|
||
'cv.addEventListener("wheel",wh,{passive:false,capture:true});' +
|
||
'cv.addEventListener("mousedown",e=>{' +
|
||
'audioKick();wTarget=cv.id;' +
|
||
'if(e.button===2){markerOn=Math.abs(markerX-e.offsetX)>=8||!markerOn;markerX=e.offsetX;return;}' +
|
||
'if(e.button!==0)return;' +
|
||
'dragging=true;dragX0=e.offsetX;' +
|
||
'dragCen0=localCenter;' +
|
||
'dragVfo0=activeVfoHz();' +
|
||
'dragMs=0;cv.style.cursor="ew-resize";' +
|
||
'});' +
|
||
'cv.addEventListener("mousemove",e=>{' +
|
||
'wTarget=cv.id;hoverX=e.offsetX;' +
|
||
'if(markerOn&&!dragging)markerX=e.offsetX;' +
|
||
'if(!dragging)return;' +
|
||
'const n=Date.now();if(n-dragMs<16)return;dragMs=n;' +
|
||
'const dHz=(e.offsetX-dragX0)/Math.max(1,cv.clientWidth)*localSpan;' +
|
||
'if(cur.ctun){' +
|
||
'localCenter=Math.round(dragCen0-dHz);' +
|
||
'ws2({cmd:"set_center",hz:localCenter});' +
|
||
'}else{' +
|
||
'const nf=cl(Math.round(dragVfo0-dHz),30000,60000000);' +
|
||
'setActiveVfoHz(nf);localCenter=nf;' +
|
||
'if(activeVfo==="A")cur.vfo_a_hz=nf;else cur.vfo_b_hz=nf;' +
|
||
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});' +
|
||
'}' +
|
||
'});' +
|
||
'cv.addEventListener("mouseup",e=>{' +
|
||
'cv.style.cursor="";' +
|
||
'if(e.button===2)return;' +
|
||
'if(dragging&&Math.abs(e.offsetX-dragX0)<4){' +
|
||
// клик — не трогаем localCenter: фильтр встаёт точно туда куда кликнули.
|
||
// Спектр остаётся на месте, сервер пришлёт center_hz через ~200ms и всё перецентрируется.
|
||
'const nf=cl(Math.round(xToHz(e.offsetX,cv.clientWidth)/100)*100,30000,60000000);' +
|
||
'setActiveVfoHz(nf);' +
|
||
'if(activeVfo==="A")cur.vfo_a_hz=nf;else cur.vfo_b_hz=nf;' +
|
||
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});' +
|
||
'}' +
|
||
'dragging=false;' +
|
||
'});' +
|
||
'cv.addEventListener("mouseleave",()=>{cv.style.cursor="";if(!markerOn)hoverX=-1;wTarget="";dragging=false;});}' +
|
||
'bindCv(specCv);bindCv(wfCv);' +
|
||
'window.addEventListener("wheel",e=>{if(e.target?.closest?.(".vfo-ro"))return;if(!wTarget){if(e.target===specCv)wTarget="spec";else if(e.target===wfCv)wTarget="wf";}if(wTarget)doWheel(e);},{passive:false,capture:true});' +
|
||
|
||
// Opus: async decodeFrame, очередь, оверлей для user gesture
|
||
'let ws=null,opDec=null,opRdy=false,opQueue=[];' +
|
||
'async function initOpus(){if(opRdy)return;' +
|
||
'try{const m=await import("https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/+esm");' +
|
||
'opDec=new m.OpusDecoder({channels:1,sampleRate:48000});' +
|
||
'await opDec.ready;opRdy=true;' +
|
||
'const q2=opQueue;opQueue=[];for(const b of q2)await playOpus(b);' +
|
||
'latEl.textContent="Opus ready";}catch(e){console.error("Opus init:",e);latEl.textContent="Opus FAILED: "+e.message;}}' +
|
||
|
||
'async function playOpus(b){' +
|
||
'if(!b||!b.length)return;' +
|
||
'if(!opRdy){opQueue.push(b);return;}' +
|
||
'if(!audioCtx)return;' +
|
||
'try{const r=await opDec.decodeFrame(b);' +
|
||
'if(!r||!r.channelData||!r.samplesDecoded)return;' +
|
||
'const pcm=r.channelData[0];' +
|
||
'const buf=audioCtx.createBuffer(1,r.samplesDecoded,48000);' +
|
||
'buf.copyToChannel(pcm.subarray(0,r.samplesDecoded),0);' +
|
||
'const src=audioCtx.createBufferSource();src.buffer=buf;src.connect(audioCtx.destination);' +
|
||
'const now=audioCtx.currentTime;' +
|
||
'if(audioT<now-.05||audioT>now+.5)audioT=now+.02;' +
|
||
'src.start(audioT);audioT+=r.samplesDecoded/48000;' +
|
||
'latEl.textContent="lat: "+Math.max(0,(audioT-now)*1000).toFixed(0)+"ms | Opus";' +
|
||
'}catch(e){console.error("playOpus:",e);}}' +
|
||
|
||
'function connect(){const p=location.protocol==="https:"?"wss":"ws";ws=new WebSocket(p+"://"+location.host+"/ws");ws.binaryType="arraybuffer";' +
|
||
'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' +
|
||
'ws.onclose=()=>{stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
|
||
'ws.onerror=()=>ws.close();' +
|
||
'ws.onmessage=e=>{if(typeof e.data==="string"){try{applyState(JSON.parse(e.data));}catch(x){}return;}' +
|
||
'const ab=e.data,t=(new DataView(ab)).getUint8(0);' +
|
||
'if(t===0x53){const n=(ab.byteLength-1)>>2;const tmp=new Uint8Array(ab,1);const al=new ArrayBuffer(n*4);new Uint8Array(al).set(tmp);const b=Array.from(new Float32Array(al));drawSpec(b);drawWf(b);drawRuler();}' +
|
||
'else if(t===0x41){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();playOpus(new Uint8Array(ab,1));}};}' +
|
||
|
||
// Оверлей для user gesture (AudioContext требует клика)
|
||
'function showAudioOverlay(){' +
|
||
'const ov=document.createElement("div");' +
|
||
'ov.style.cssText="position:fixed;inset:0;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;z-index:9999;cursor:pointer;";' +
|
||
'const inner=document.createElement("div");' +
|
||
'inner.textContent="\u25BA Click to start audio";' +
|
||
'inner.style.cssText="color:#7fff7f;font-size:22px;font-family:monospace;text-align:center;padding:20px;border:1px solid #3d7a3d;border-radius:6px;background:#0c1a0c;";' +
|
||
'ov.appendChild(inner);' +
|
||
'ov.addEventListener("click",function(){audioKick();ov.remove();},{once:true});' +
|
||
'document.body.appendChild(ov);}' +
|
||
|
||
'window.addEventListener("resize",resize);resize();renderVfos();connect();' +
|
||
'document.addEventListener("click",function(){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();});' +
|
||
'setTimeout(showAudioOverlay,300);' +
|
||
'</script></body></html>';
|
||
end;
|
||
|
||
end.
|