Files
ewsdr/WebPageHtml.pas
T
2026-03-05 16:19:26 +03:00

443 lines
34 KiB
ObjectPascal
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.
unit WebPageHtml;
{
WebPageHtml.pas — Встроенный HTML/CSS/JS интерфейс веб-пульта HPSDR.
Содержит единственную функцию GetIndexHtml, которая возвращает полную
HTML-страницу (одним файлом) для отправки клиенту по HTTP GET /.
Страница включает:
- CSS: тёмная тема, VFO, спектр, водопад, S-метр
- HTML: разметка панелей управления
- JS: WebSocket клиент, отрисовка спектра/водопада/S-метра,
VFO отображение и управление, Opus декодер (CDN)
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
function GetIndexHtml: string;
implementation
function GetIndexHtml: string;
begin
Result :=
// ── <head>: мета, шрифты, CSS ──────────────────────────────────────────
'<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<title>HPSDR Web Remote</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}' +
'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}' +
// ── VFO-строка ──
'.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;background:#0c0c0c;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}' +
'.vfo-ro.dim{color:#2a4a2a;background:#1e1e1e;border:none}' +
'.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:#1e281e}' +
'.vfo-ro .sp{display:inline-block;color:#2a6030;min-width:0.3em;font-size:44px;line-height:1}' +
'.vfo-ro.dim .sp{color:#1a2a1a}' +
'.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:#2a4a2a}' +
'.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>: разметка панелей ────────────────────────────────────────────
'<body><div class="wrap">' +
// Тулбар 1: громкость, AGC, режимы, DSP кнопки
'<div class="tbar">' +
'<span class="vol-ic" id="muteIc" title="Mute">&#128266;</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>' +
'<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="anfBtn">ANF</button>' +
'<button id="nrBtn">NR</button>' +
'</div>' +
// Тулбар 2: S-метр, VFO A/B, RX/TX, диапазон, CW
'<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="rxTxBtn">RX</button>' +
'<button id="cwBtn">CW</button>' +
'<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>: весь клиентский JS ───────────────────────────────────────
'<script>' +
// Ссылки на canvas и контексты
'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 dragging=false,dragX0=0,dragCen0=0,dragVfo0=0,dragMs=0;' +
'let hoverX=-1,wTarget="",markerOn=false,markerX=0;' +
'let specSm=[],wfAvg=[],wfHi=-50,wfLo=-120;' +
'let audioCtx=null,audioT=0;' +
// AudioContext
'function audioKick(){if(!audioCtx){audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:48000});audioT=audioCtx.currentTime+0.05;}if(audioCtx.state!=="running")audioCtx.resume();}' +
// Цветовая палитра водопада (256 цветов)
'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';
Result := Result +
'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});}' +
// Режимы и группы
'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]};' +
'function modGrp(){return q("modGrpSel").value||"ssb";}' +
'function modTglTarget(){const grp=GRP_MAP[modGrp()];const cur_m=cur.mode||0;return(cur_m===grp[0])?grp[1]:grp[0];}' +
'function updModTglLabel(){const b=q("modTglBtn");if(!b)return;b.textContent=MODE_N[modTglTarget()];}' +
'function syncModGrp(m){const g=m<=1?"ssb":m<=4?"cw":m===5?"fm":"am";const s=q("modGrpSel");if(s&&s.value!==g)s.value=g;}' +
'q("modGrpSel").onchange=function(){updModTglLabel();};' +
'q("modTglBtn").onclick=function(){audioKick();ws2({cmd:"mode",mode:modTglTarget()});};' +
'q("cwBtn").onclick=function(){audioKick();ws2({cmd:"mode",mode:cur.mode===3?4:3});};' +
'q("agcSel").onchange=function(e){ws2({cmd:"agc",mode:+e.target.value});};' +
// Слайдеры
'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=q("txVal");if(v)v.textContent=e.target.value;};' +
// DSP кнопки
'q("nrBtn").onclick=function(){cmd("set_nr",!cur.nr);};' +
'q("nbBtn").onclick=function(){cmd("set_nb",!cur.nb);};' +
'q("anfBtn").onclick=function(){cmd("set_anf",!cur.anf);};' +
// VFO отображение
'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);}' +
// Привязка событий VFO (колесо, клик по цифре, dblclick)
'function bindVfo(dispId,getHz,setHz,getSt,setSt,getMem,setMem){' +
'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);renderVfos();ws2({cmd:"freq",hz: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();ws2({cmd:"freq",hz:nf});' +
'});}' +
'bindVfo("vDispA",()=>vfoA,v=>{vfoA=v;},()=>stA,v=>{stA=v;},()=>stAmem,v=>{stAmem=v;});' +
'bindVfo("vDispB",()=>vfoB,v=>{vfoB=v;},()=>stB,v=>{stB=v;},()=>stBmem,v=>{stBmem=v;});' +
// Переключение активного VFO, кнопки A=B/B=A/SWAP
'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");ws2({cmd:"freq",hz:Math.round(w==="A"?vfoA:vfoB)});}' +
'q("vBtnA").onclick=()=>selVfo("A");' +
'q("vBtnB").onclick=()=>selVfo("B");' +
'q("btnBA").onclick=()=>{vfoA=vfoB;renderVfos();ws2({cmd:"freq",hz:Math.round(vfoA)});};' +
'q("btnAB").onclick=()=>{vfoB=vfoA;renderVfos();};' +
'q("btnSwap").onclick=()=>{const t=vfoA;vfoA=vfoB;vfoB=t;renderVfos();ws2({cmd:"freq",hz:Math.round(activeVfo==="A"?vfoA:vfoB)});};' +
// RX/TX и Mute
'q("rxTxBtn").onclick=()=>{audioKick();cmd("set_run",!cur.running);};' +
'function updRunBtn(r){const b=q("rxTxBtn");b.textContent=r?"TX":"RX";b.classList.toggle("rx-tx",!!r);}' +
'q("muteIc").onclick=()=>{cur.mute=!cur.mute;q("muteIc").textContent=cur.mute?"\uD83D\uDD07":"\uD83D\uDD0A";cmd("set_mute",cur.mute);};' +
// Кнопки выбора полосы обзора (SPAN)
'const SPANS=[3000,6000,12000,24000,48000,96000,192000,384000,480000,960000];' +
'const SPAN_L=["3K","6K","12K","24K","48K","96K","192K","384K","480K","960K"];' +
'(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;}' +
// S-метр
'let smAvg=-130,smPeak=-130,smMin=-130;' +
'function dbToX(db,bx,bw){return Math.max(bx,Math.min(bx+bw,Math.round(bx+(db+127)/127*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,0];' +
'const DBM_LABELS=["-120","-100","-80","-60","-40","-20","0"];' +
'const S_MARKS_DBM=[-121,-115,-109,-103,-93,-83,-73,-53,-33];' +
'const S_MARKS_LBL=["S1","S3","S5","S7","S9","+10","+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<0;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<-93){let sv=Math.round((dbm+127)/6);if(sv<1)sv=1;if(sv>9)sv=9;sLbl="S"+sv;}' +
'else sLbl="S9+"+Math.round(dbm+93);' +
'mctx.fillStyle="#F0F0F0";mctx.font="bold 12px Courier New";mctx.fillText(sLbl,1,YB+TICK_LONG+10);}' +
// Применение state от сервера
'function applyState(msg){' +
'cur=msg;vfoA=msg.vfo_a_hz||vfoA;renderVfos();' +
'const as=q("agcSel");if(as&&msg.agc_mode!=null)as.value=msg.agc_mode;' +
'const vs=q("volSl");if(vs&&!vs.matches(":active")){vs.value=msg.volume||70;var vv=q("volVal");if(vv)vv.textContent=msg.volume||70;}' +
'const rs=q("rfSl");if(rs&&!rs.matches(":active")){rs.value=msg.agc_top||90;var rv=q("rfVal");if(rv)rv.textContent=(msg.agc_top||90)+"dB";}' +
'sw("nrBtn",!!msg.nr);sw("nbBtn",!!msg.nb);sw("anfBtn",!!msg.anf);' +
'updRunBtn(!!msg.running);syncModGrp(msg.mode||0);updModTglLabel();' +
'updBand(msg.vfo_a_hz||0);updSpan(msg.span_hz||192000);' +
'paintSM(Number(msg.smeter_dbm||-130));' +
'const mn=msg.mode_name||(MODE_N[msg.mode||0]||"?");' +
'stEl.textContent=(msg.connected?"OK":"OFF")+" | "+(msg.running?"RUN":"STOP")+" | "+mn+" | "+((vfoA/1e6).toFixed(3))+" MHz";}' +
// Waterfall palette lookup и фильтр
'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(cur.center_hz||0)+((x/Math.max(1,w))-.5)*(cur.span_hz||0);}' +
'function hzToX(f,w){return((f-(cur.center_hz||0))/(cur.span_hz||1)+.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=(cur.center_hz||0)-(cur.span_hz||0)/2;' +
'for(let i=0;i<=8;i++){const x=i*w/8,f=st+i*(cur.span_hz||0)/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 vx=hzToX(vfoA,w);rctx.strokeStyle="#60d070";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(vx+.5,0);rctx.lineTo(vx+.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[fl,fh]=fltLH(),x1=hzToX(vfoA+fl,w),x2=hzToX(vfoA+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(vfoA,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[fl,fh]=fltLH(),x1=hzToX(vfoA+fl,w),x2=hzToX(vfoA+fh,w);' +
'wctx.fillStyle="rgba(220,230,230,.10)";wctx.fillRect(Math.min(x1,x2),0,Math.abs(x2-x1),h);' +
'const vx=hzToX(vfoA,w);wctx.strokeStyle="#60ff80";wctx.lineWidth=1;wctx.beginPath();wctx.moveTo(vx,0);wctx.lineTo(vx,h);wctx.stroke();drawMk(wctx,w,h);}' +
// Resize с учётом devicePixelRatio
'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 doWheel(e){e.preventDefault();e.stopPropagation();audioKick();const dir=e.deltaY<0?1:-1,st=tuneStep(e),base=Math.floor(vfoA/st)*st;vfoA=cl(dir>0?base+st:(Math.round(vfoA)===base?base-st:base),30000,60000000);cur.vfo_a_hz=vfoA;renderVfos();ws2({cmd:"freq",hz:vfoA});}' +
'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=cur.center_hz||0;dragVfo0=vfoA;dragMs=0;});' +
'cv.addEventListener("mousemove",e=>{wTarget=cv.id;hoverX=e.offsetX;if(markerOn)markerX=e.offsetX;if(!dragging)return;const n=Date.now();if(n-dragMs<35)return;dragMs=n;const dHz=(e.offsetX-dragX0)/Math.max(1,cv.clientWidth)*(cur.span_hz||0);if(cur.ctun)ws2({cmd:"freq",hz:Math.round(dragCen0-dHz)});else{vfoA=cl(Math.round(dragVfo0-dHz),30000,60000000);cur.vfo_a_hz=vfoA;renderVfos();ws2({cmd:"freq",hz:vfoA});}});' +
'cv.addEventListener("mouseup",e=>{if(e.button===2)return;if(Math.abs(e.offsetX-dragX0)<4){vfoA=cl(Math.round(xToHz(e.offsetX,cv.clientWidth)/100)*100,30000,60000000);cur.vfo_a_hz=vfoA;renderVfos();ws2({cmd:"freq",hz:vfoA});}dragging=false;});' +
'cv.addEventListener("mouseleave",()=>{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 декодер (CDN) и воспроизведение
'let ws=null,opDec=null,opRdy=false;' +
'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;}catch(e){console.error("Opus init:",e);}}' +
'function playOpus(b){if(!opRdy||!audioCtx||!b?.length)return;' +
'try{const r=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);}}' +
// WebSocket подключение и обработка сообщений
'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().then(()=>{latEl.textContent=opRdy?"Opus ready":"Opus FAILED";});};' +
'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){audioKick();if(opRdy)playOpus(new Uint8Array(ab,1));}};}' +
// Инициализация
'window.addEventListener("resize",resize);resize();renderVfos();connect();' +
'document.addEventListener("click",()=>{if(audioCtx?.state==="suspended")audioCtx.resume();});' +
'</script></body></html>';
end;
end.