Add embedded HTML header for curve editor page

Minified HTML/CSS/JS that will be served directly from code
without needing filesystem upload
This commit is contained in:
dawie 2026-01-30 19:16:09 +02:00
parent e2d1a8f74f
commit b13dbb2897

View File

@ -0,0 +1,339 @@
#pragma once
// GPU Fan Controller - Embedded Web Page
// This is served directly from code at /gpu-fan
const char GPU_FAN_HTML[] PROGMEM = R"=====(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GPU Fan</title>
<style>
:root{--bg:#1a1a2e;--card:#16213e;--acc:#0f3460;--hl:#e94560;--txt:#eee;--dim:#888;--ok:#4ade80;--warn:#fbbf24;--err:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--txt);padding:15px}
.c{max-width:700px;margin:0 auto}
h1{text-align:center;margin-bottom:15px;color:var(--hl);font-size:1.5em}
.card{background:var(--card);border-radius:10px;padding:15px;margin-bottom:15px}
.card h2{font-size:.9em;margin-bottom:12px;color:var(--dim);text-transform:uppercase}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.stat{text-align:center;padding:10px;background:var(--acc);border-radius:6px}
.val{font-size:1.5em;font-weight:bold}
.lbl{font-size:.7em;color:var(--dim);margin-top:3px}
.t-val{color:var(--warn)}.s-val{color:var(--ok)}.m-val{color:var(--hl)}
.graph{position:relative;width:100%;height:200px;background:var(--acc);border-radius:6px;overflow:hidden}
svg{width:100%;height:100%}
.gline{stroke:#fff3;stroke-width:1}
.albl{fill:var(--dim);font-size:9px}
.curve{fill:none;stroke:var(--hl);stroke-width:2.5}
.area{fill:url(#grad);opacity:.3}
.point{fill:var(--hl);stroke:#fff;stroke-width:2;cursor:grab}
.point:hover{r:9;fill:#fff}
.tline{stroke:var(--warn);stroke-width:2;stroke-dasharray:4,4}
.tdot{fill:var(--warn);stroke:#fff;stroke-width:2}
.btns{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.btn{padding:8px 16px;border:none;border-radius:5px;cursor:pointer;font-size:.85em}
.btn-s{background:var(--acc);color:var(--txt)}
.btn-g{background:var(--ok);color:#000}
.mode{display:flex;background:var(--acc);border-radius:6px;padding:3px;margin-bottom:12px}
.mode button{flex:1;padding:8px;border:none;background:0;color:var(--dim);cursor:pointer;border-radius:4px;font-size:.85em}
.mode button.on{background:var(--hl);color:#fff}
.slider{padding:12px 0}
.slider.hide{display:none}
.slider input{width:100%;height:6px;-webkit-appearance:none;background:var(--acc);border-radius:3px}
.slider input::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:20px;background:var(--hl);border-radius:50%;cursor:pointer}
.slider .v{text-align:center;font-size:1.3em;margin-top:8px}
table{width:100%;margin-top:12px;border-collapse:collapse;font-size:.85em}
th,td{padding:6px;text-align:center;border-bottom:1px solid var(--acc)}
table input{width:50px;padding:4px;border:1px solid var(--acc);border-radius:3px;background:var(--bg);color:var(--txt);text-align:center}
.toast{position:fixed;bottom:15px;left:50%;transform:translateX(-50%);padding:10px 20px;background:var(--ok);color:#000;border-radius:6px;opacity:0;transition:opacity .3s;font-size:.9em}
.toast.show{opacity:1}
.toast.err{background:var(--err);color:#fff}
.back{display:inline-block;margin-bottom:10px;color:var(--dim);text-decoration:none;font-size:.9em}
</style>
</head>
<body>
<div class="c">
<a href="/" class="back">&#8592; WLED</a>
<h1>&#127744; GPU Fan Controller</h1>
<div class="card">
<h2>Status</h2>
<div class="grid">
<div class="stat"><div class="val t-val" id="temp">--</div><div class="lbl">Temp °C</div></div>
<div class="stat"><div class="val s-val" id="speed">--</div><div class="lbl">Fan %</div></div>
<div class="stat"><div class="val m-val" id="mode">--</div><div class="lbl">Mode</div></div>
<div class="stat"><div class="val" id="status" style="color:var(--ok)">&#9679;</div><div class="lbl" id="stxt">OK</div></div>
</div>
</div>
<div class="card">
<h2>Mode</h2>
<div class="mode">
<button id="btnF" onclick="setMode(0)">Fixed</button>
<button id="btnC" class="on" onclick="setMode(1)">Curve</button>
</div>
<div class="slider hide" id="fCtrl">
<input type="range" id="fSlider" min="0" max="100" value="50" oninput="setFixed(this.value)">
<div class="v"><span id="fVal">50</span>%</div>
</div>
<div id="cCtrl">
<div class="graph">
<svg id="svg" viewBox="0 0 400 200">
<defs><linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#e94560;stop-opacity:.5"/>
<stop offset="100%" style="stop-color:#e94560;stop-opacity:0"/>
</linearGradient></defs>
<g id="grid"></g>
<g id="labels"></g>
<line id="tLine" class="tline" x1="0" y1="0" x2="0" y2="200"/>
<path id="area" class="area"/>
<path id="curve" class="curve"/>
<circle id="tDot" class="tdot" r="5" cx="0" cy="0"/>
<g id="points"></g>
</svg>
</div>
<table>
<thead><tr><th>#</th><th>Temp</th><th>Speed</th></tr></thead>
<tbody id="tbl"></tbody>
</table>
<div class="btns">
<button class="btn btn-s" onclick="addPt()">+ Add</button>
<button class="btn btn-s" onclick="remPt()">- Remove</button>
<button class="btn btn-s" onclick="reset()">Reset</button>
<button class="btn btn-g" onclick="save()">Save</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const P={l:40,r:15,t:15,b:25},W=400,H=200;
const GW=W-P.l-P.r,GH=H-P.t-P.b;
const TN=20,TX=100,SN=0,SX=100;
let pts=[{t:30,s:30},{t:50,s:50},{t:70,s:75},{t:85,s:100}];
let cMode=1,fSpd=50,cTemp=25,drag=null;
document.addEventListener('DOMContentLoaded',()=>{
drawGrid();loadCfg();draw();
setInterval(poll,2000);
});
function tX(t){return P.l+((t-TN)/(TX-TN))*GW}
function sY(s){return P.t+(1-(s-SN)/(SX-SN))*GH}
function xT(x){return TN+((x-P.l)/GW)*(TX-TN)}
function yS(y){return SX-((y-P.t)/GH)*(SX-SN)}
function drawGrid(){
let g=document.getElementById('grid'),l=document.getElementById('labels');
for(let t=20;t<=100;t+=20){
let x=tX(t);
g.innerHTML+=`<line class="gline" x1="${x}" y1="${P.t}" x2="${x}" y2="${H-P.b}"/>`;
l.innerHTML+=`<text class="albl" x="${x}" y="${H-8}" text-anchor="middle">${t}</text>`;
}
for(let s=0;s<=100;s+=25){
let y=sY(s);
g.innerHTML+=`<line class="gline" x1="${P.l}" y1="${y}" x2="${W-P.r}" y2="${y}"/>`;
l.innerHTML+=`<text class="albl" x="${P.l-5}" y="${y+3}" text-anchor="end">${s}</text>`;
}
}
function draw(){
pts.sort((a,b)=>a.t-b.t);
let path='',area='';
pts.forEach((p,i)=>{
let x=tX(p.t),y=sY(p.s);
path+=(i?'L':'M')+x+','+y+' ';
});
if(pts.length){
area=path+`L${tX(pts[pts.length-1].t)},${sY(0)} L${tX(pts[0].t)},${sY(0)} Z`;
}
document.getElementById('curve').setAttribute('d',path);
document.getElementById('area').setAttribute('d',area);
let pg=document.getElementById('points');pg.innerHTML='';
pts.forEach((p,i)=>{
let c=document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('class','point');
c.setAttribute('cx',tX(p.t));
c.setAttribute('cy',sY(p.s));
c.setAttribute('r',7);
c.setAttribute('data-i',i);
c.onmousedown=c.ontouchstart=startDrag;
pg.appendChild(c);
});
drawTbl();
drawTemp();
}
function drawTbl(){
let tb=document.getElementById('tbl');tb.innerHTML='';
pts.forEach((p,i)=>{
tb.innerHTML+=`<tr><td>${i+1}</td>
<td><input type="number" value="${p.t}" min="0" max="100" onchange="setPt(${i},'t',this.value)"></td>
<td><input type="number" value="${p.s}" min="0" max="100" onchange="setPt(${i},'s',this.value)"></td></tr>`;
});
}
function setPt(i,k,v){pts[i][k]=parseInt(v);draw()}
function drawTemp(){
let x=tX(cTemp);
document.getElementById('tLine').setAttribute('x1',x);
document.getElementById('tLine').setAttribute('x2',x);
let s=calcSpd(cTemp);
document.getElementById('tDot').setAttribute('cx',x);
document.getElementById('tDot').setAttribute('cy',sY(s));
}
function calcSpd(t){
if(pts.length<2)return 50;
if(t<=pts[0].t)return pts[0].s;
if(t>=pts[pts.length-1].t)return pts[pts.length-1].s;
for(let i=0;i<pts.length-1;i++){
if(t>=pts[i].t&&t<=pts[i+1].t){
let r=(t-pts[i].t)/(pts[i+1].t-pts[i].t);
return pts[i].s+r*(pts[i+1].s-pts[i].s);
}
}
return 50;
}
function startDrag(e){
e.preventDefault();
drag=parseInt(e.target.getAttribute('data-i'));
document.onmousemove=document.ontouchmove=doDrag;
document.onmouseup=document.ontouchend=endDrag;
}
function doDrag(e){
if(drag===null)return;
e.preventDefault();
let svg=document.getElementById('svg');
let rect=svg.getBoundingClientRect();
let cx=e.touches?e.touches[0].clientX:e.clientX;
let cy=e.touches?e.touches[0].clientY:e.clientY;
let x=(cx-rect.left)*(W/rect.width);
let y=(cy-rect.top)*(H/rect.height);
pts[drag].t=Math.max(TN,Math.min(TX,Math.round(xT(x))));
pts[drag].s=Math.max(SN,Math.min(SX,Math.round(yS(y))));
draw();
}
function endDrag(){
drag=null;
document.onmousemove=document.ontouchmove=null;
document.onmouseup=document.ontouchend=null;
}
function addPt(){
if(pts.length>=5){toast('Max 5',1);return}
let mt=(pts[0].t+pts[pts.length-1].t)/2;
pts.push({t:Math.round(mt),s:Math.round(calcSpd(mt))});
draw();
}
function remPt(){
if(pts.length<=2){toast('Min 2',1);return}
pts.pop();draw();
}
function reset(){
pts=[{t:30,s:30},{t:50,s:50},{t:70,s:75},{t:85,s:100}];
draw();
}
function setMode(m){
cMode=m;
document.getElementById('btnF').classList.toggle('on',m===0);
document.getElementById('btnC').classList.toggle('on',m===1);
document.getElementById('fCtrl').classList.toggle('hide',m!==0);
document.getElementById('cCtrl').style.display=m===0?'none':'block';
}
function setFixed(v){
fSpd=parseInt(v);
document.getElementById('fVal').textContent=fSpd;
fetch('/json/state',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({'GPU-Fan':{speed:fSpd}})});
}
async function loadCfg(){
try{
let r=await fetch('/cfg.json');
let cfg=await r.json();
let f=cfg.um?.['GPU-Fan'];
if(f){
cMode=f.mode||1;
fSpd=f['fixed-speed']||50;
pts=[];
let n=f['curve-points']||4;
for(let i=1;i<=n&&i<=5;i++){
let t=f['curve-t'+i],s=f['curve-s'+i];
if(t!==undefined&&s!==undefined)pts.push({t,s});
}
if(pts.length<2)reset();
setMode(cMode);
document.getElementById('fSlider').value=fSpd;
document.getElementById('fVal').textContent=fSpd;
draw();
}
}catch(e){console.error(e)}
}
async function poll(){
try{
let r=await fetch('/json/info');
let info=await r.json();
let u=info.u;
if(u){
if(u['GPU Temp']){cTemp=parseFloat(u['GPU Temp'][0])||25;document.getElementById('temp').textContent=cTemp.toFixed(1)}
if(u['Fan Speed'])document.getElementById('speed').textContent=parseInt(u['Fan Speed'][0])||0;
if(u['Fan Mode'])document.getElementById('mode').textContent=u['Fan Mode'][0];
}
drawTemp();
document.getElementById('status').style.color='var(--ok)';
document.getElementById('stxt').textContent='OK';
}catch(e){
document.getElementById('status').style.color='var(--err)';
document.getElementById('stxt').textContent='Err';
}
}
async function save(){
pts.sort((a,b)=>a.t-b.t);
let cfg={'GPU-Fan':{enabled:true,mode:cMode,'fixed-speed':fSpd,'curve-points':pts.length}};
for(let i=0;i<5;i++){
cfg['GPU-Fan']['curve-t'+(i+1)]=i<pts.length?pts[i].t:50;
cfg['GPU-Fan']['curve-s'+(i+1)]=i<pts.length?pts[i].s:50;
}
try{
let r=await fetch('/cfg.json',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({um:cfg})});
if(r.ok){
toast('Saved!');
fetch('/json/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({'GPU-Fan':{mode:cMode}})});
}else toast('Fail',1);
}catch(e){toast('Err',1)}
}
function toast(m,e){
let t=document.getElementById('toast');
t.textContent=m;
t.classList.toggle('err',!!e);
t.classList.add('show');
setTimeout(()=>t.classList.remove('show'),2000);
}
</script>
</body>
</html>
)=====";