157 lines
7.0 KiB
C++
157 lines
7.0 KiB
C++
#ifndef GRAPH_H
|
|
#define GRAPH_H
|
|
|
|
const char graph_html[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html lang="ko"><head>
|
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>그래프 - CANFD Logger</title>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
|
|
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
|
|
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
|
|
.title{color:#e6edf3;font-weight:700;font-size:15px}
|
|
.container{padding:12px}
|
|
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:10px}
|
|
.card h3{font-size:12px;color:#8b949e;margin-bottom:12px;text-transform:uppercase}
|
|
input[type=text],select{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
|
|
.btn{padding:5px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600}
|
|
.btn-blue{background:#1f6feb;color:#fff} .btn-green{background:#238636;color:#fff}
|
|
.btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
|
|
.row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:8px}
|
|
.field{margin-bottom:4px} label{display:block;font-size:11px;color:#8b949e;margin-bottom:3px}
|
|
canvas{width:100%;height:300px;background:#0d1117;border:1px solid #30363d;border-radius:6px}
|
|
.sig-list{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
|
|
.sig-chip{background:#21262d;border:1px solid #30363d;border-radius:12px;padding:3px 10px;font-size:11px;display:flex;align-items:center;gap:6px}
|
|
.sig-color{width:10px;height:10px;border-radius:50%;display:inline-block}
|
|
</style>
|
|
</head><body>
|
|
<nav>
|
|
<span class="title">⚡ CANFD Logger</span>
|
|
<a href="/">대시보드</a><a href="/transmit">송신</a>
|
|
<a href="/graph" class="active">그래프</a><a href="/settings">설정</a>
|
|
</nav>
|
|
<div class="container">
|
|
<div class="card">
|
|
<h3>실시간 신호 그래프 (CAN FD)</h3>
|
|
<div class="row">
|
|
<div class="field"><label>CAN ID (hex)</label><input type="text" id="sigId" value="0x123" style="width:90px"></div>
|
|
<div class="field"><label>시작 비트</label><input type="text" id="sigStart" value="0" style="width:60px"></div>
|
|
<div class="field"><label>비트 길이</label><input type="text" id="sigLen" value="8" style="width:60px"></div>
|
|
<div class="field"><label>배율</label><input type="text" id="sigScale" value="1" style="width:60px"></div>
|
|
<div class="field"><label>이름</label><input type="text" id="sigName" value="Signal 1" style="width:90px"></div>
|
|
<div class="field" style="margin-bottom:4px"><button class="btn btn-blue" onclick="addSignal()">+ 추가</button></div>
|
|
</div>
|
|
<div class="sig-list" id="sigList"></div>
|
|
<canvas id="chart"></canvas>
|
|
<div style="margin-top:8px;display:flex;gap:8px">
|
|
<button class="btn btn-gray" onclick="clearSignals()">신호 초기화</button>
|
|
<span style="font-size:11px;color:#8b949e;margin-top:8px">최대 8개 신호, 500 포인트 표시</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const COLORS=['#58a6ff','#3fb950','#d29922','#f85149','#a371f7','#ffa657','#79c0ff','#56d364'];
|
|
const DLC_LEN=[0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64];
|
|
let signals=[], ws, reconnTimer;
|
|
const MAX_PTS=500;
|
|
|
|
function addSignal(){
|
|
if(signals.length>=8) return alert('최대 8개');
|
|
let id=parseInt(document.getElementById('sigId').value,16);
|
|
let s=parseInt(document.getElementById('sigStart').value);
|
|
let l=parseInt(document.getElementById('sigLen').value);
|
|
let sc=parseFloat(document.getElementById('sigScale').value)||1;
|
|
let nm=document.getElementById('sigName').value||('Signal '+(signals.length+1));
|
|
let color=COLORS[signals.length%COLORS.length];
|
|
signals.push({id,startBit:s,bitLen:l,scale:sc,name:nm,color,pts:[],lastVal:null});
|
|
renderChips(); draw();
|
|
}
|
|
|
|
function clearSignals(){ signals=[]; renderChips(); draw(); }
|
|
|
|
function renderChips(){
|
|
let html='';
|
|
signals.forEach((s,i)=>{ html+=`<div class="sig-chip"><span class="sig-color" style="background:${s.color}"></span>${s.name} (0x${s.id.toString(16).toUpperCase()})<span onclick="removeSignal(${i})" style="cursor:pointer;color:#8b949e">✕</span></div>`; });
|
|
document.getElementById('sigList').innerHTML=html;
|
|
}
|
|
function removeSignal(i){ signals.splice(i,1); renderChips(); draw(); }
|
|
|
|
function extractBits(data,start,len){
|
|
let v=0;
|
|
for(let i=0;i<len;i++){
|
|
let bit=start+i; let b=Math.floor(bit/8); let bit2=7-(bit%8);
|
|
if(b<data.length&&(data[b]&(1<<bit2))) v|=(1<<(len-1-i));
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function processMsg(m){
|
|
signals.forEach(s=>{
|
|
if(s.id===m.id){
|
|
let v=extractBits(m.data||[],s.startBit,s.bitLen)*s.scale;
|
|
let ts=Date.now();
|
|
s.pts.push({x:ts,y:v}); s.lastVal=v;
|
|
if(s.pts.length>MAX_PTS) s.pts.shift();
|
|
}
|
|
});
|
|
draw();
|
|
}
|
|
|
|
const cv=document.getElementById('chart');
|
|
const ctx=cv.getContext('2d');
|
|
function draw(){
|
|
let W=cv.offsetWidth, H=cv.offsetHeight;
|
|
cv.width=W; cv.height=H;
|
|
ctx.fillStyle='#0d1117'; ctx.fillRect(0,0,W,H);
|
|
if(signals.length===0){
|
|
ctx.fillStyle='#8b949e'; ctx.font='13px sans-serif'; ctx.textAlign='center';
|
|
ctx.fillText('신호를 추가하면 실시간 그래프가 표시됩니다.',W/2,H/2); return;
|
|
}
|
|
let allPts=signals.flatMap(s=>s.pts);
|
|
if(allPts.length<2) return;
|
|
let now=Date.now();
|
|
let minX=now-30000, maxX=now;
|
|
let allY=allPts.map(p=>p.y);
|
|
let minY=Math.min(...allY), maxY=Math.max(...allY);
|
|
if(minY===maxY){minY-=1;maxY+=1;}
|
|
let pad=30;
|
|
function toX(x){return pad+(x-minX)/(maxX-minX)*(W-pad*2);}
|
|
function toY(y){return H-pad-(y-minY)/(maxY-minY)*(H-pad*2);}
|
|
// 격자
|
|
ctx.strokeStyle='#21262d'; ctx.lineWidth=1;
|
|
for(let i=0;i<=4;i++){let y=pad+i*(H-pad*2)/4;ctx.beginPath();ctx.moveTo(pad,y);ctx.lineTo(W-pad,y);ctx.stroke();}
|
|
// 축 레이블
|
|
ctx.fillStyle='#8b949e'; ctx.font='10px sans-serif'; ctx.textAlign='right';
|
|
for(let i=0;i<=4;i++){let y=pad+i*(H-pad*2)/4;let v=(maxY-(maxY-minY)*i/4).toFixed(2);ctx.fillText(v,pad-4,y+4);}
|
|
// 신호
|
|
signals.forEach(s=>{
|
|
if(s.pts.length<2) return;
|
|
ctx.strokeStyle=s.color; ctx.lineWidth=1.5; ctx.beginPath();
|
|
s.pts.forEach((p,i)=>{let x=toX(p.x),y=toY(p.y);i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);});
|
|
ctx.stroke();
|
|
// 현재값
|
|
let last=s.pts[s.pts.length-1];
|
|
ctx.fillStyle=s.color; ctx.font='11px monospace'; ctx.textAlign='left';
|
|
ctx.fillText(s.name+': '+(s.lastVal!==null?s.lastVal.toFixed(2):'--'), toX(last.x)+4, toY(last.y)-4);
|
|
});
|
|
}
|
|
|
|
function connect(){
|
|
ws=new WebSocket('ws://'+location.hostname+':81/');
|
|
ws.onopen=()=>clearTimeout(reconnTimer);
|
|
ws.onclose=()=>reconnTimer=setTimeout(connect,3000);
|
|
ws.onmessage=e=>{
|
|
try{
|
|
let d=JSON.parse(e.data);
|
|
if(d.type==='update'&&d.messages) d.messages.forEach(m=>processMsg(m));
|
|
}catch(ex){}
|
|
};
|
|
}
|
|
connect();
|
|
setInterval(draw,100);
|
|
</script>
|
|
</body></html>
|
|
)rawliteral";
|
|
#endif |