Add visual curve editor web page

Features:
- Interactive SVG graph with draggable curve points
- Live GPU temperature display with indicator on graph  
- Real-time fan speed monitoring
- Mode toggle between Fixed and Curve modes
- Point table for precise value entry
- Touch support for mobile devices
- Minified CSS for smaller file size
This commit is contained in:
dawie 2026-01-30 18:53:32 +02:00
parent f0873c1c6e
commit 12c17bf6f4

View File

@ -0,0 +1,345 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GPU Fan Controller</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:20px}
.c{max-width:800px;margin:0 auto}
h1{text-align:center;margin-bottom:20px;color:var(--hl)}
.card{background:var(--card);border-radius:12px;padding:20px;margin-bottom:20px}
.card h2{font-size:1em;margin-bottom:15px;color:var(--dim);text-transform:uppercase}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:15px}
.stat{text-align:center;padding:15px;background:var(--acc);border-radius:8px}
.val{font-size:2em;font-weight:bold}
.lbl{font-size:.8em;color:var(--dim);margin-top:5px}
.t-val{color:var(--warn)}.s-val{color:var(--ok)}.m-val{color:var(--hl)}
.graph{position:relative;width:100%;padding-top:60%;background:var(--acc);border-radius:8px;overflow:hidden}
svg{position:absolute;top:0;left:0;width:100%;height:100%}
.gline{stroke:#fff3;stroke-width:1}
.albl{fill:var(--dim);font-size:10px}
.curve{fill:none;stroke:var(--hl);stroke-width:3}
.area{fill:url(#grad);opacity:.3}
.point{fill:var(--hl);stroke:#fff;stroke-width:2;cursor:grab}
.point:hover{r:10;fill:#fff}
.tline{stroke:var(--warn);stroke-width:2;stroke-dasharray:5,5}
.tdot{fill:var(--warn);stroke:#fff;stroke-width:2}
.btns{display:flex;flex-wrap:wrap;gap:10px;margin-top:15px}
.btn{padding:10px 20px;border:none;border-radius:6px;cursor:pointer;font-weight:500}
.btn-p{background:var(--hl);color:#fff}
.btn-s{background:var(--acc);color:var(--txt)}
.btn-g{background:var(--ok);color:#000}
.mode{display:flex;background:var(--acc);border-radius:8px;padding:4px;margin-bottom:15px}
.mode button{flex:1;padding:10px;border:none;background:0;color:var(--dim);cursor:pointer;border-radius:6px}
.mode button.on{background:var(--hl);color:#fff}
.slider{padding:15px 0}
.slider.hide{display:none}
.slider input{width:100%;height:8px;-webkit-appearance:none;background:var(--acc);border-radius:4px}
.slider input::-webkit-slider-thumb{-webkit-appearance:none;width:24px;height:24px;background:var(--hl);border-radius:50%;cursor:pointer}
.slider .v{text-align:center;font-size:1.5em;margin-top:10px}
table{width:100%;margin-top:15px;border-collapse:collapse}
th,td{padding:8px;text-align:center;border-bottom:1px solid var(--acc)}
table input{width:60px;padding:5px;border:1px solid var(--acc);border-radius:4px;background:var(--bg);color:var(--txt);text-align:center}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);padding:12px 24px;background:var(--ok);color:#000;border-radius:8px;opacity:0;transition:opacity .3s}
.toast.show{opacity:1}
.toast.err{background:var(--err);color:#fff}
.back{display:inline-block;margin-bottom:15px;color:var(--dim);text-decoration:none}
</style>
</head>
<body>
<div class="c">
<a href="/" class="back">← Back to WLED</a>
<h1>🌀 GPU Fan Controller</h1>
<div class="card">
<h2>Live Status</h2>
<div class="grid">
<div class="stat"><div class="val t-val" id="temp">--</div><div class="lbl">GPU Temp (°C)</div></div>
<div class="stat"><div class="val s-val" id="speed">--</div><div class="lbl">Fan Speed (%)</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)"></div><div class="lbl" id="statTxt">Connected</div></div>
</div>
</div>
<div class="card">
<h2>Control Mode</h2>
<div class="mode">
<button id="btnF" onclick="setMode(0)">Fixed Speed</button>
<button id="btnC" class="on" onclick="setMode(1)">Temperature Curve</button>
</div>
<div class="slider hide" id="fixedCtrl">
<input type="range" id="fixedSlider" min="0" max="100" value="50" oninput="setFixed(this.value)">
<div class="v"><span id="fixedVal">50</span>%</div>
</div>
<div id="curveCtrl">
<div class="graph">
<svg id="svg" viewBox="0 0 400 240">
<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="tempLine" class="tline" x1="0" y1="0" x2="0" y2="240"/>
<path id="area" class="area"/>
<path id="curve" class="curve"/>
<circle id="tempDot" class="tdot" r="6" cx="0" cy="0"/>
<g id="points"></g>
</svg>
</div>
<table>
<thead><tr><th>Point</th><th>Temp (°C)</th><th>Speed (%)</th></tr></thead>
<tbody id="table"></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>
</div>
</div>
</div>
<div class="card">
<div class="btns" style="justify-content:center">
<button class="btn btn-g" onclick="save()" style="padding:15px 40px;font-size:1.1em">💾 Save</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const PAD={l:45,r:20,t:20,b:30},W=400,H=240;
const GW=W-PAD.l-PAD.r,GH=H-PAD.t-PAD.b;
const TMIN=20,TMAX=100,SMIN=0,SMAX=100;
let pts=[{t:30,s:30},{t:50,s:50},{t:70,s:75},{t:85,s:100}];
let curMode=1,fixSpd=50,curTemp=25,drag=null;
document.addEventListener('DOMContentLoaded',()=>{
drawGrid();loadCfg();draw();
setInterval(poll,2000);
});
function tX(t){return PAD.l+((t-TMIN)/(TMAX-TMIN))*GW}
function sY(s){return PAD.t+(1-(s-SMIN)/(SMAX-SMIN))*GH}
function xT(x){return TMIN+((x-PAD.l)/GW)*(TMAX-TMIN)}
function yS(y){return SMAX-((y-PAD.t)/GH)*(SMAX-SMIN)}
function drawGrid(){
let g=document.getElementById('grid'),l=document.getElementById('labels');
g.innerHTML='';l.innerHTML='';
for(let t=20;t<=100;t+=20){
let x=tX(t);
g.innerHTML+=`<line class="gline" x1="${x}" y1="${PAD.t}" x2="${x}" y2="${H-PAD.b}"/>`;
l.innerHTML+=`<text class="albl" x="${x}" y="${H-10}" text-anchor="middle">${t}°</text>`;
}
for(let s=0;s<=100;s+=25){
let y=sY(s);
g.innerHTML+=`<line class="gline" x1="${PAD.l}" y1="${y}" x2="${W-PAD.r}" y2="${y}"/>`;
l.innerHTML+=`<text class="albl" x="${PAD.l-8}" y="${y+4}" 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',8);
c.setAttribute('data-i',i);
c.onmousedown=startDrag;
c.ontouchstart=startDrag;
pg.appendChild(c);
});
drawTable();
drawTemp();
}
function drawTable(){
let tb=document.getElementById('table');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(curTemp);
document.getElementById('tempLine').setAttribute('x1',x);
document.getElementById('tempLine').setAttribute('x2',x);
let s=calcSpeed(curTemp);
document.getElementById('tempDot').setAttribute('cx',x);
document.getElementById('tempDot').setAttribute('cy',sY(s));
}
function calcSpeed(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=doDrag;
document.onmouseup=endDrag;
document.ontouchmove=doDrag;
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);
let t=Math.round(xT(x)),s=Math.round(yS(y));
pts[drag].t=Math.max(TMIN,Math.min(TMAX,t));
pts[drag].s=Math.max(SMIN,Math.min(SMAX,s));
draw();
}
function endDrag(){
drag=null;
document.onmousemove=null;
document.onmouseup=null;
document.ontouchmove=null;
document.ontouchend=null;
}
function addPt(){
if(pts.length>=5){toast('Max 5 points',1);return}
let mt=(pts[0].t+pts[pts.length-1].t)/2;
pts.push({t:Math.round(mt),s:Math.round(calcSpeed(mt))});
draw();
}
function remPt(){
if(pts.length<=2){toast('Min 2 points',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){
curMode=m;
document.getElementById('btnF').classList.toggle('on',m===0);
document.getElementById('btnC').classList.toggle('on',m===1);
document.getElementById('fixedCtrl').classList.toggle('hide',m!==0);
document.getElementById('curveCtrl').style.display=m===0?'none':'block';
}
function setFixed(v){
fixSpd=parseInt(v);
document.getElementById('fixedVal').textContent=fixSpd;
fetch('/json/state',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({'GPU-Fan':{speed:fixSpd}})});
}
async function loadCfg(){
try{
let r=await fetch('/cfg.json');
let cfg=await r.json();
let f=cfg.um?.['GPU-Fan'];
if(f){
curMode=f.mode||1;
fixSpd=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(curMode);
document.getElementById('fixedSlider').value=fixSpd;
document.getElementById('fixedVal').textContent=fixSpd;
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']){curTemp=parseFloat(u['GPU Temp'][0])||25;document.getElementById('temp').textContent=curTemp.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('statTxt').textContent='Connected';
}catch(e){
document.getElementById('status').style.color='var(--err)';
document.getElementById('statTxt').textContent='Disconnected';
}
}
async function save(){
pts.sort((a,b)=>a.t-b.t);
let cfg={'GPU-Fan':{enabled:true,mode:curMode,'fixed-speed':fixSpd,'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:curMode}})});
}else toast('Failed',1);
}catch(e){toast('Error: '+e.message,1)}
}
function toast(msg,err){
let t=document.getElementById('toast');
t.textContent=msg;
t.classList.toggle('err',!!err);
t.classList.add('show');
setTimeout(()=>t.classList.remove('show'),3000);
}
</script>
</body>
</html>