기본 동작 확인
This commit is contained in:
222
graph_viewer.h
Normal file
222
graph_viewer.h
Normal file
@@ -0,0 +1,222 @@
|
||||
#ifndef GRAPH_VIEWER_H
|
||||
#define GRAPH_VIEWER_H
|
||||
|
||||
|
||||
const char graph_viewer_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;max-width:1000px}
|
||||
.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}
|
||||
.btn{padding:5px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600}
|
||||
.btn-blue{background:#1f6feb;color:#fff} .btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
|
||||
input[type=text],input[type=file]{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
|
||||
.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}
|
||||
.info{font-size:11px;color:#8b949e;margin-top:6px}
|
||||
.stat{display:inline-block;margin-right:16px;color:#8b949e}
|
||||
.stat b{color:#e6edf3}
|
||||
</style>
|
||||
</head><body>
|
||||
<nav>
|
||||
<span class="title">⚡ CANFD Logger</span>
|
||||
<a href="/">대시보드</a><a href="/transmit">송신</a>
|
||||
<a href="/graph">그래프</a><a href="/graph-view" class="active">뷰어</a>
|
||||
<a href="/settings">설정</a>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h3>BIN 파일 분석 뷰어 (CAN FD)</h3>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>BIN 파일 선택 (로컬)</label>
|
||||
<input type="file" id="binFile" accept=".bin" onchange="loadBin()">
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:4px">
|
||||
<button class="btn btn-gray" onclick="document.getElementById('dlUrl').style.display='block'">URL로 열기</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dlUrl" style="display:none;margin-bottom:8px">
|
||||
<label>다운로드 URL (SD 파일명)</label>
|
||||
<div class="row">
|
||||
<input type="text" id="urlInput" placeholder="예: CANFD_20250430_120000.bin" style="width:260px">
|
||||
<button class="btn btn-blue" onclick="loadUrl()">불러오기</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fileInfo" class="info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="analyzeCard" style="display:none">
|
||||
<h3>신호 설정</h3>
|
||||
<div class="row">
|
||||
<div class="field"><label>CAN ID (hex)</label><input type="text" id="vSigId" value="0x123" style="width:90px"></div>
|
||||
<div class="field"><label>시작 비트</label><input type="text" id="vSigStart" value="0" style="width:60px"></div>
|
||||
<div class="field"><label>비트 길이</label><input type="text" id="vSigLen" value="8" style="width:60px"></div>
|
||||
<div class="field"><label>배율</label><input type="text" id="vSigScale" value="1" style="width:60px"></div>
|
||||
<div class="field"><label>이름</label><input type="text" id="vSigName" value="Signal 1" style="width:90px"></div>
|
||||
<div class="field" style="margin-bottom:4px"><button class="btn btn-blue" onclick="addSig()">+ 추가</button></div>
|
||||
</div>
|
||||
<div id="vSigList" style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0"></div>
|
||||
<canvas id="vChart"></canvas>
|
||||
<div style="margin-top:8px">
|
||||
<span class="stat">시간 범위: <b id="timeRange">-</b></span>
|
||||
<span class="stat">총 메시지: <b id="totalMsgs">0</b></span>
|
||||
<span class="stat">FD 메시지: <b id="fdMsgs">0</b></span>
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<button class="btn btn-gray" onclick="clearSigs()">신호 초기화</button>
|
||||
<button class="btn btn-blue" onclick="exportCSV()">CSV 내보내기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COLORS=['#58a6ff','#3fb950','#d29922','#f85149','#a371f7','#ffa657','#79c0ff','#56d364'];
|
||||
const RECORD_SIZE=79; // sizeof(CANFDLog): 8+4+1+1+1+64
|
||||
let records=[], signals=[];
|
||||
|
||||
function parseBin(buf){
|
||||
records=[];
|
||||
let n=Math.floor(buf.byteLength/RECORD_SIZE);
|
||||
let dv=new DataView(buf);
|
||||
for(let i=0;i<n;i++){
|
||||
let off=i*RECORD_SIZE;
|
||||
let tsLo=dv.getUint32(off,true), tsHi=dv.getUint32(off+4,true);
|
||||
let ts=tsHi*0x100000000+tsLo; // uint64 → JS Number (μs, precision ok for logging)
|
||||
let id=dv.getUint32(off+8,true);
|
||||
let dlc=dv.getUint8(off+12);
|
||||
let len=dv.getUint8(off+13);
|
||||
let flags=dv.getUint8(off+14);
|
||||
let data=new Uint8Array(buf,off+15,Math.min(len,64));
|
||||
records.push({ts,id,dlc,len,flags,data:Array.from(data)});
|
||||
}
|
||||
let total=records.length;
|
||||
let fdCnt=records.filter(r=>(r.flags&0x20)!==0).length;
|
||||
document.getElementById('totalMsgs').textContent=total.toLocaleString();
|
||||
document.getElementById('fdMsgs').textContent=fdCnt.toLocaleString();
|
||||
if(total>0){
|
||||
let t0=records[0].ts, t1=records[total-1].ts;
|
||||
document.getElementById('timeRange').textContent=((t1-t0)/1e6).toFixed(3)+'s';
|
||||
document.getElementById('fileInfo').textContent=
|
||||
`총 ${total.toLocaleString()} 메시지 | FD: ${fdCnt.toLocaleString()} | 시간: ${((t1-t0)/1e6).toFixed(3)}s`;
|
||||
}
|
||||
document.getElementById('analyzeCard').style.display='block';
|
||||
draw();
|
||||
}
|
||||
|
||||
function loadBin(){
|
||||
let f=document.getElementById('binFile').files[0]; if(!f) return;
|
||||
document.getElementById('fileInfo').textContent='읽는 중...';
|
||||
let r=new FileReader();
|
||||
r.onload=e=>parseBin(e.target.result);
|
||||
r.readAsArrayBuffer(f);
|
||||
}
|
||||
|
||||
function loadUrl(){
|
||||
let fn=document.getElementById('urlInput').value.trim(); if(!fn) return;
|
||||
document.getElementById('fileInfo').textContent='다운로드 중...';
|
||||
fetch('/download?file='+fn).then(r=>r.arrayBuffer()).then(parseBin)
|
||||
.catch(e=>{ document.getElementById('fileInfo').textContent='다운로드 실패: '+e; });
|
||||
}
|
||||
|
||||
function extractBits(data,start,len){
|
||||
let v=0;
|
||||
for(let i=0;i<len;i++){
|
||||
let bit=start+i,b=Math.floor(bit/8),bit2=7-(bit%8);
|
||||
if(b<data.length&&(data[b]&(1<<bit2))) v|=(1<<(len-1-i));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function addSig(){
|
||||
if(signals.length>=8) return alert('최대 8개');
|
||||
let id=parseInt(document.getElementById('vSigId').value,16);
|
||||
signals.push({id,startBit:parseInt(document.getElementById('vSigStart').value)||0,
|
||||
bitLen:parseInt(document.getElementById('vSigLen').value)||8,
|
||||
scale:parseFloat(document.getElementById('vSigScale').value)||1,
|
||||
name:document.getElementById('vSigName').value||'Signal',
|
||||
color:COLORS[signals.length%COLORS.length]});
|
||||
renderSigList(); draw();
|
||||
}
|
||||
function clearSigs(){signals=[];renderSigList();draw();}
|
||||
function removeSig(i){signals.splice(i,1);renderSigList();draw();}
|
||||
function renderSigList(){
|
||||
let h='';
|
||||
signals.forEach((s,i)=>h+=`<span style="background:#21262d;border:1px solid #30363d;border-radius:12px;padding:3px 10px;font-size:11px;display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:50%;background:${s.color};display:inline-block"></span>${s.name}<span onclick="removeSig(${i})" style="cursor:pointer;color:#8b949e">✕</span></span>`);
|
||||
document.getElementById('vSigList').innerHTML=h;
|
||||
}
|
||||
|
||||
const cv=document.getElementById('vChart');
|
||||
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(!records.length||!signals.length){
|
||||
ctx.fillStyle='#8b949e';ctx.font='13px sans-serif';ctx.textAlign='center';
|
||||
ctx.fillText(records.length?'신호를 추가하세요.':'BIN 파일을 로드하세요.',W/2,H/2); return;
|
||||
}
|
||||
// 각 신호별 포인트 계산
|
||||
let seriesList=signals.map(s=>{
|
||||
let pts=records.filter(r=>r.id===s.id).map(r=>({x:r.ts,y:extractBits(r.data,s.startBit,s.bitLen)*s.scale}));
|
||||
return {s,pts};
|
||||
}).filter(s=>s.pts.length>0);
|
||||
if(!seriesList.length) return;
|
||||
let allPts=seriesList.flatMap(s=>s.pts);
|
||||
let minX=Math.min(...allPts.map(p=>p.x)), maxX=Math.max(...allPts.map(p=>p.x));
|
||||
let minY=Math.min(...allPts.map(p=>p.y)), maxY=Math.max(...allPts.map(p=>p.y));
|
||||
if(minY===maxY){minY-=1;maxY+=1;}
|
||||
let pad=35;
|
||||
function toX(x){return pad+(x-minX)/(maxX-minX||1)*(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);}
|
||||
// X축 레이블
|
||||
ctx.textAlign='center';
|
||||
for(let i=0;i<=4;i++){let x=pad+i*(W-pad*2)/4;let t=((minX+(maxX-minX)*i/4-minX)/1e6).toFixed(2)+'s';ctx.fillText(t,x,H-4);}
|
||||
// 신호 그리기
|
||||
seriesList.forEach(({s,pts})=>{
|
||||
ctx.strokeStyle=s.color;ctx.lineWidth=1.5;ctx.beginPath();
|
||||
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();
|
||||
ctx.fillStyle=s.color;ctx.font='11px monospace';ctx.textAlign='left';
|
||||
let last=pts[pts.length-1];
|
||||
ctx.fillText(s.name+': '+last.y.toFixed(2),toX(last.x)+4,toY(last.y)-4);
|
||||
});
|
||||
}
|
||||
|
||||
function exportCSV(){
|
||||
if(!records.length) return;
|
||||
let cols=['Time_us','ID','DLC','Len','FD','BRS','EXT'];
|
||||
signals.forEach(s=>cols.push(s.name));
|
||||
let rows=[cols.join(',')];
|
||||
records.forEach(r=>{
|
||||
let row=[r.ts,(r.id>>>0).toString(16).toUpperCase(),r.dlc,r.len,
|
||||
(r.flags&0x20)?1:0,(r.flags&0x10)?1:0,(r.flags&0x80)?1:0];
|
||||
signals.forEach(s=>{
|
||||
if(s.id===r.id) row.push((extractBits(r.data,s.startBit,s.bitLen)*s.scale).toFixed(4));
|
||||
else row.push('');
|
||||
});
|
||||
rows.push(row.join(','));
|
||||
});
|
||||
let blob=new Blob([rows.join('\n')],{type:'text/csv'});
|
||||
let a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
||||
a.download='canfd_export.csv';a.click();
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
)rawliteral";
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user