디자인 통일
This commit is contained in:
614
graph.h
614
graph.h
@@ -1,156 +1,498 @@
|
||||
#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>
|
||||
<!DOCTYPE html><html><head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>CAN FD Signal Selector</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}
|
||||
:root{--bg:#0e1117;--panel:#161b24;--card:#1c2230;--border:#2d3748;--accent:#43cea2;--blue:#58a6ff;--red:#f85149;--yellow:#e3b341;--text:#e6edf3;--muted:#8b949e;--r:8px;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}html,body{min-height:100%;}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden;font-size:14px;}
|
||||
.header{background:linear-gradient(135deg,#1a2744 0%,#1e1a3a 100%);padding:11px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
|
||||
.header h1{font-size:1.0em;font-weight:700;color:var(--accent);}
|
||||
.header p{font-size:.78em;color:var(--muted);margin:0;}
|
||||
.header-spacer{flex:1;}
|
||||
nav{background:var(--panel);border-bottom:1px solid var(--border);display:flex;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch;}
|
||||
nav::-webkit-scrollbar{display:none;}
|
||||
nav .nav-title{color:var(--accent);font-weight:700;font-size:1.0em;padding:10px 14px;white-space:nowrap;border-bottom:2px solid transparent;}
|
||||
nav a{display:inline-flex;align-items:center;padding:10px 13px;text-decoration:none;color:var(--muted);font-size:.78em;font-weight:500;border-bottom:2px solid transparent;white-space:nowrap;transition:color .2s,border-color .2s;}
|
||||
nav a:hover{color:var(--text);}
|
||||
nav a.active{color:var(--accent);border-bottom-color:var(--accent);}
|
||||
.content{padding:12px;}
|
||||
h2{color:var(--accent);margin:14px 0 10px;font-size:.82em;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding-bottom:6px;border-bottom:1px solid var(--border);}
|
||||
.btn,button{padding:6px 13px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--muted);font-size:.8em;font-weight:600;cursor:pointer;font-family:inherit;transition:all .15s;white-space:nowrap;-webkit-tap-highlight-color:transparent;touch-action:manipulation;}
|
||||
.btn:hover,button:hover{border-color:var(--accent);color:var(--accent);}
|
||||
.btn:active,button:active{transform:scale(.97);}
|
||||
.btn-primary{border-color:var(--blue);color:var(--blue);}
|
||||
.btn-success{border-color:var(--accent);color:var(--accent);}
|
||||
.btn-danger{border-color:var(--red);color:var(--red);}
|
||||
.btn-secondary{border-color:var(--muted);color:var(--muted);}
|
||||
.btn-primary:hover{background:rgba(88,166,255,.10);}
|
||||
.btn-success:hover{background:rgba(67,206,162,.10);}
|
||||
.btn-danger:hover{background:rgba(248,81,73,.10);}
|
||||
.btn-secondary:hover{background:rgba(139,148,158,.10);}
|
||||
.upload-area{border:2px dashed var(--border);border-radius:var(--r);padding:26px 18px;text-align:center;cursor:pointer;transition:all .2s;color:var(--muted);font-size:.85em;background:var(--panel);margin-bottom:10px;}
|
||||
.upload-area:hover{border-color:var(--accent);color:var(--accent);background:rgba(67,206,162,.04);}
|
||||
.upload-area input{display:none;}
|
||||
.signal-selector{background:var(--panel);border:1px solid var(--border);border-radius:var(--r);padding:13px;margin-bottom:10px;}
|
||||
.search-wrap{position:relative;margin-bottom:8px;}
|
||||
.search-box{width:100%;padding:8px 10px 8px 34px;border:1px solid var(--border);border-radius:var(--r);font-size:.85em;background:var(--bg);color:var(--text);}
|
||||
.search-box:focus{outline:none;border-color:var(--accent);}
|
||||
.search-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--accent);}
|
||||
.sort-bar{display:flex;gap:5px;margin-bottom:8px;align-items:center;flex-wrap:wrap;}
|
||||
.sort-label{font-size:.77em;color:var(--muted);font-weight:600;}
|
||||
.sort-btn{padding:3px 9px;border:1px solid var(--border);background:var(--bg);border-radius:5px;font-size:.75em;cursor:pointer;font-weight:600;color:var(--muted);font-family:inherit;transition:all .15s;}
|
||||
.sort-btn:hover{border-color:var(--accent);color:var(--accent);}
|
||||
.sort-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(67,206,162,.10);}
|
||||
/* ★ 고정 높이 가상 스크롤 컨테이너 */
|
||||
.sig-scroll{height:460px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;}
|
||||
.signal-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,170px),1fr));gap:6px;padding:8px;}
|
||||
.signal-item{background:var(--card);padding:8px;border-radius:var(--r);border:1px solid var(--border);cursor:pointer;transition:border-color .1s,background .1s;user-select:none;}
|
||||
.signal-item:hover{border-color:var(--accent);background:rgba(67,206,162,.04);}
|
||||
.signal-item.selected{border-color:var(--accent);background:rgba(67,206,162,.12);}
|
||||
.sig-name{font-weight:600;color:var(--text);margin-bottom:2px;font-size:.82em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.sig-info{font-size:.73em;color:var(--muted);line-height:1.35;}
|
||||
.hl{background:rgba(227,179,65,.25);color:var(--yellow);font-weight:600;}
|
||||
.fd-badge{display:inline-block;padding:1px 4px;border-radius:3px;font-size:.68em;font-weight:700;background:#2d1b69;color:#a371f7;margin-left:2px;}
|
||||
.sel-info{background:rgba(88,166,255,.07);padding:8px 12px;border-radius:var(--r);margin-bottom:10px;border:1px solid rgba(88,166,255,.2);border-left:3px solid var(--blue);font-size:.83em;}
|
||||
.sel-info strong{color:var(--blue);}
|
||||
.controls{display:flex;gap:5px;margin-bottom:10px;flex-wrap:wrap;}
|
||||
.status{padding:8px 12px;border-radius:var(--r);margin-bottom:8px;font-size:.82em;display:none;}
|
||||
.status.success{background:rgba(67,206,162,.08);border:1px solid rgba(67,206,162,.25);color:var(--accent);display:block;}
|
||||
.status.error{background:rgba(248,81,73,.08);border:1px solid rgba(248,81,73,.25);color:var(--red);display:block;}
|
||||
.more-btn{display:block;width:100%;padding:8px;text-align:center;background:var(--panel);border:none;border-top:1px solid var(--border);color:var(--muted);font-size:.8em;cursor:pointer;font-family:inherit;}
|
||||
.more-btn:hover{color:var(--accent);}
|
||||
.info-tip{background:rgba(88,166,255,.07);padding:10px 13px;border-radius:8px;margin-top:10px;border:1px solid rgba(88,166,255,.2);border-left:3px solid var(--blue);font-size:.82em;}
|
||||
@media(max-width:480px){.content{padding:8px;}.sig-scroll{height:340px;}}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="header">
|
||||
<div><h1>CAN FD Signal Graph</h1><p>DBC Signal Selector</p></div>
|
||||
<div class="header-spacer"></div>
|
||||
</div>
|
||||
<nav>
|
||||
<span class="title">⚡ CANFD Logger</span>
|
||||
<a href="/">대시보드</a><a href="/transmit">송신</a>
|
||||
<a href="/graph" class="active">그래프</a><a href="/settings">설정</a>
|
||||
<span class="nav-title">⚡ CANFD Logger</span>
|
||||
<a href="/">대시보드</a>
|
||||
<a href="/transmit">송신</a>
|
||||
<a href="/graph" class="active">그래프</a>
|
||||
<a href="/graph-view">그래프 뷰어</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 class="content">
|
||||
<div id="status" class="status"></div>
|
||||
<h2>DBC 파일 업로드</h2>
|
||||
<div class="upload-area" id="upload-area" onclick="document.getElementById('dbc-file').click()">
|
||||
<input type="file" id="dbc-file" accept=".dbc" onchange="loadDBCFile(event)">
|
||||
<p style="font-size:1.05em;margin-bottom:6px;">클릭 또는 드래그&드롭으로 DBC 업로드</p>
|
||||
<p id="dbc-status" style="color:var(--muted);font-size:.85em;">파일 없음 — localStorage 복원 가능</p>
|
||||
</div>
|
||||
|
||||
<div id="signal-section" style="display:none;">
|
||||
<h2>신호 선택 (최대 20개)</h2>
|
||||
<div class="sel-info">
|
||||
선택됨: <strong><span id="sel-count">0</span> / 20</strong>
|
||||
| 표시: <strong><span id="show-count">0</span></strong> / <span id="total-count">0</span>
|
||||
</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 class="controls">
|
||||
<button class="btn btn-success" onclick="startGraphing()">▶ 실시간 그래프 시작</button>
|
||||
<button class="btn btn-primary" onclick="clearSelection()">선택 초기화</button>
|
||||
</div>
|
||||
<div class="signal-selector">
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" id="search-box" class="search-box"
|
||||
placeholder="신호명 / CAN ID / 메시지명 / 단위 검색..."
|
||||
oninput="scheduleFilter()">
|
||||
</div>
|
||||
<div class="sort-bar">
|
||||
<span class="sort-label">정렬:</span>
|
||||
<button class="sort-btn active" id="sort-selection" onclick="setSortMode('selection')">선택순</button>
|
||||
<button class="sort-btn" id="sort-name-asc" onclick="setSortMode('name-asc')">A→Z</button>
|
||||
<button class="sort-btn" id="sort-name-desc" onclick="setSortMode('name-desc')">Z→A</button>
|
||||
<button class="sort-btn" id="sort-msgid" onclick="setSortMode('msgid')">ID순</button>
|
||||
</div>
|
||||
<!-- ★ 고정 높이 스크롤 컨테이너 — 전체 DOM 생성 방지 -->
|
||||
<div class="sig-scroll" id="sig-scroll">
|
||||
<div class="signal-grid" id="signal-grid"></div>
|
||||
<button class="more-btn" id="more-btn" style="display:none" onclick="showMore()">
|
||||
+ 더 보기 (<span id="more-count">0</span>개 남음)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-tip">
|
||||
<strong>Info:</strong> Start 버튼 → 새 창에서 실시간 그래프. 신호 선택은 자동 저장됩니다.
|
||||
</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;
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 상수
|
||||
// ════════════════════════════════════════════════════════
|
||||
const PAGE_SIZE = 100; // ★ 한 번에 렌더링할 최대 신호 수
|
||||
const MAX_SEL = 20;
|
||||
|
||||
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();
|
||||
}
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 상태
|
||||
// ════════════════════════════════════════════════════════
|
||||
let dbcData = {messages:{}, valueTables:{}};
|
||||
let allSignals = [];
|
||||
let filteredSigs = []; // 현재 검색/정렬된 전체 결과
|
||||
let shownCount = 0; // 현재 DOM에 그려진 수
|
||||
let selectedSet = new Set(); // ★ O(1) 선택 확인 ("msgId|name" 키)
|
||||
let selectedSigs = []; // 순서 보존용 배열
|
||||
let sortMode = 'selection';
|
||||
let searchQuery = '';
|
||||
let filterTimer = null; // ★ 디바운스 타이머
|
||||
let hlRegex = null; // ★ 검색어 regex 캐시 (매 신호마다 생성 방지)
|
||||
let dbcFilename = '';
|
||||
|
||||
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){}
|
||||
// ════════════════════════════════════════════════════════
|
||||
// DBC 로드
|
||||
// ════════════════════════════════════════════════════════
|
||||
function loadDBCFile(event) {
|
||||
const file = event.target.files[0]; if (!file) return;
|
||||
showStatus('DBC 파싱 중...', 'success');
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
parseDBCContent(e.target.result, file.name);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
connect();
|
||||
setInterval(draw,100);
|
||||
|
||||
// ★ DBC 파일 자체는 localStorage에 저장하지 않음 (용량/속도 문제)
|
||||
// → 파일명과 선택된 신호만 저장
|
||||
function saveState() {
|
||||
try {
|
||||
localStorage.setItem('selected_signals', JSON.stringify(selectedSigs));
|
||||
localStorage.setItem('sort_mode', sortMode);
|
||||
localStorage.setItem('dbc_filename', dbcFilename);
|
||||
// graph_viewer.h 호환용 마커 (전체 DBC 텍스트 대신)
|
||||
localStorage.setItem('dbc_loaded', '1');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function parseDBCContent(text, filename) {
|
||||
dbcData = {messages:{}, valueTables:{}};
|
||||
allSignals = [];
|
||||
dbcFilename = filename || dbcFilename || 'unknown.dbc';
|
||||
|
||||
const lines = text.split(/\r?\n/);
|
||||
let curMsg = null;
|
||||
|
||||
// 1단계: VAL_ 파싱
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line.startsWith('VAL_ ')) continue;
|
||||
const m = line.match(/VAL_\s+(\d+)\s+(\w+)\s+(.+);/);
|
||||
if (!m) continue;
|
||||
const msgId = parseInt(m[1]) & 0x1FFFFFFF;
|
||||
const key = msgId + '|' + m[2];
|
||||
dbcData.valueTables[key] = {};
|
||||
for (let vm of m[3].matchAll(/(\d+)\s+"([^"]+)"/g))
|
||||
dbcData.valueTables[key][parseInt(vm[1])] = vm[2];
|
||||
}
|
||||
|
||||
// 2단계: BO_ + SG_ 파싱
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (line.startsWith('BO_ ')) {
|
||||
const m = line.match(/BO_\s+(\d+)\s+(\S+)\s*:\s*(\d+)/);
|
||||
if (!m) continue;
|
||||
let id = parseInt(m[1]);
|
||||
if (id & 0x80000000) id &= 0x1FFFFFFF;
|
||||
curMsg = {id, name: m[2], dlc: parseInt(m[3]), signals: []};
|
||||
dbcData.messages[id] = curMsg;
|
||||
} else if (line.startsWith('SG_') && curMsg) {
|
||||
const m = line.match(/SG_\s+(\S+)\s*(?:[Mm]\d*\s*)?:\s*(\d+)\|(\d+)@([01])([+-])\s*\(([^,]+),([^)]+)\)\s*\[([^\]]*)\]\s*"([^"]*)"/);
|
||||
if (!m) continue;
|
||||
const sigName = m[1];
|
||||
const sig = {
|
||||
name: sigName,
|
||||
startBit: parseInt(m[2]),
|
||||
bitLength: parseInt(m[3]),
|
||||
byteOrder: m[4]==='0' ? 'motorola' : 'intel',
|
||||
signed: m[5]==='-',
|
||||
factor: parseFloat(m[6]) || 1,
|
||||
offset: parseFloat(m[7]) || 0,
|
||||
unit: m[9],
|
||||
messageId: curMsg.id,
|
||||
messageName: curMsg.name,
|
||||
messageDlc: curMsg.dlc,
|
||||
isFD: curMsg.dlc > 8
|
||||
};
|
||||
const vtKey = curMsg.id + '|' + sigName;
|
||||
if (dbcData.valueTables[vtKey]) sig.valueTable = dbcData.valueTables[vtKey];
|
||||
curMsg.signals.push(sig);
|
||||
allSignals.push(sig);
|
||||
}
|
||||
}
|
||||
|
||||
const msgCnt = Object.keys(dbcData.messages).length;
|
||||
document.getElementById('dbc-status').textContent =
|
||||
dbcFilename + ' — ' + msgCnt + '개 메시지, ' + allSignals.length + '개 신호';
|
||||
showStatus('DBC 로드 완료: ' + msgCnt + '메시지 / ' + allSignals.length + '신호', 'success');
|
||||
|
||||
// 선택 복원
|
||||
const savedSigs = _restoreSelectedFromStorage();
|
||||
runFilter();
|
||||
if (savedSigs > 0) showStatus('이전 선택 ' + savedSigs + '개 복원됨', 'success');
|
||||
}
|
||||
|
||||
function _restoreSelectedFromStorage() {
|
||||
try {
|
||||
const saved = localStorage.getItem('selected_signals');
|
||||
const sm = localStorage.getItem('sort_mode');
|
||||
if (sm) { sortMode = sm; _updateSortBtns(); }
|
||||
if (!saved) return 0;
|
||||
const list = JSON.parse(saved);
|
||||
let count = 0;
|
||||
for (const ss of list) {
|
||||
const found = allSignals.find(s => s.messageId===ss.messageId && s.name===ss.name);
|
||||
if (found && selectedSigs.length < MAX_SEL) {
|
||||
_addToSelection(found);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
} catch(e) { return 0; }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 검색 / 필터 (★ 디바운스 250ms)
|
||||
// ════════════════════════════════════════════════════════
|
||||
function scheduleFilter() {
|
||||
clearTimeout(filterTimer);
|
||||
filterTimer = setTimeout(runFilter, 250);
|
||||
}
|
||||
|
||||
function runFilter() {
|
||||
searchQuery = document.getElementById('search-box').value.trim();
|
||||
|
||||
// ★ 검색어 regex를 한 번만 컴파일
|
||||
if (searchQuery) {
|
||||
try {
|
||||
hlRegex = new RegExp('(' + searchQuery.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')', 'gi');
|
||||
} catch { hlRegex = null; }
|
||||
} else {
|
||||
hlRegex = null;
|
||||
}
|
||||
|
||||
// 필터
|
||||
const q = searchQuery.toLowerCase();
|
||||
if (!q) {
|
||||
filteredSigs = [...allSignals];
|
||||
} else {
|
||||
filteredSigs = allSignals.filter(s =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.messageId.toString().includes(q) ||
|
||||
s.messageId.toString(16).toLowerCase().includes(q.replace('0x','')) ||
|
||||
s.messageName.toLowerCase().includes(q) ||
|
||||
(s.unit && s.unit.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
_sortInPlace(filteredSigs);
|
||||
|
||||
// 초기 렌더 (첫 PAGE_SIZE 개)
|
||||
shownCount = 0;
|
||||
document.getElementById('signal-grid').innerHTML = '';
|
||||
document.getElementById('total-count').textContent = filteredSigs.length;
|
||||
document.getElementById('signal-section').style.display = 'block';
|
||||
|
||||
_renderBatch();
|
||||
}
|
||||
|
||||
// ★ 핵심: 한 번에 PAGE_SIZE개만 렌더링
|
||||
function _renderBatch() {
|
||||
const list = document.getElementById('signal-grid');
|
||||
const end = Math.min(shownCount + PAGE_SIZE, filteredSigs.length);
|
||||
const frag = document.createDocumentFragment(); // ★ DocumentFragment — 한 번만 레이아웃
|
||||
|
||||
for (let i = shownCount; i < end; i++) {
|
||||
frag.appendChild(_makeItem(filteredSigs[i]));
|
||||
}
|
||||
list.appendChild(frag);
|
||||
shownCount = end;
|
||||
|
||||
document.getElementById('show-count').textContent = shownCount;
|
||||
|
||||
// "더 보기" 버튼
|
||||
const remaining = filteredSigs.length - shownCount;
|
||||
const moreBtn = document.getElementById('more-btn');
|
||||
if (remaining > 0) {
|
||||
document.getElementById('more-count').textContent = remaining;
|
||||
moreBtn.style.display = 'block';
|
||||
} else {
|
||||
moreBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showMore() {
|
||||
_renderBatch();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 신호 아이템 생성
|
||||
// ════════════════════════════════════════════════════════
|
||||
function _makeItem(sig) {
|
||||
const key = sig.messageId + '|' + sig.name;
|
||||
const isSel = selectedSet.has(key);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'signal-item' + (isSel ? ' selected' : '');
|
||||
div.dataset.key = key;
|
||||
|
||||
const idHex = '0x' + sig.messageId.toString(16).toUpperCase().padStart(3,'0');
|
||||
const fd = sig.isFD ? '<span class="fd-badge">FD</span>' : '';
|
||||
|
||||
// ★ innerHTML에 regex 적용은 이미 컴파일된 hlRegex 재사용
|
||||
div.innerHTML =
|
||||
'<div class="sig-name">' + _hl(sig.name) + fd + '</div>' +
|
||||
'<div class="sig-info">' +
|
||||
'ID: ' + _hl(idHex) + ' (' + _hl(sig.messageName) + ')<br>' +
|
||||
sig.startBit + '|' + sig.bitLength + 'b' +
|
||||
(sig.unit ? ' <span style="color:var(--blue)">' + _hl(sig.unit) + '</span>' : '') +
|
||||
'</div>';
|
||||
|
||||
div.onclick = () => _toggleItem(div, sig, key);
|
||||
return div;
|
||||
}
|
||||
|
||||
function _hl(text) {
|
||||
if (!hlRegex) return text;
|
||||
hlRegex.lastIndex = 0;
|
||||
return text.replace(hlRegex, '<span class="hl">$1</span>');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 선택 관리 (Set 기반 O(1))
|
||||
// ════════════════════════════════════════════════════════
|
||||
function _addToSelection(sig) {
|
||||
const key = sig.messageId + '|' + sig.name;
|
||||
if (!selectedSet.has(key)) {
|
||||
selectedSet.add(key);
|
||||
selectedSigs.push(sig);
|
||||
}
|
||||
}
|
||||
|
||||
function _toggleItem(el, sig, key) {
|
||||
if (selectedSet.has(key)) {
|
||||
selectedSet.delete(key);
|
||||
selectedSigs = selectedSigs.filter(s => (s.messageId+'|'+s.name) !== key);
|
||||
el.classList.remove('selected');
|
||||
} else {
|
||||
if (selectedSigs.length >= MAX_SEL) {
|
||||
showStatus('최대 ' + MAX_SEL + '개까지 선택 가능합니다.', 'error');
|
||||
return;
|
||||
}
|
||||
selectedSet.add(key);
|
||||
selectedSigs.push(sig);
|
||||
el.classList.add('selected');
|
||||
}
|
||||
document.getElementById('sel-count').textContent = selectedSigs.length;
|
||||
saveState();
|
||||
// ★ 선택순 정렬이면 전체 재렌더 필요 (다른 정렬은 불필요)
|
||||
if (sortMode === 'selection') runFilter();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedSet.clear();
|
||||
selectedSigs = [];
|
||||
document.getElementById('sel-count').textContent = 0;
|
||||
document.querySelectorAll('.signal-item.selected').forEach(e => e.classList.remove('selected'));
|
||||
saveState();
|
||||
if (sortMode === 'selection') runFilter();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 정렬
|
||||
// ════════════════════════════════════════════════════════
|
||||
function setSortMode(mode) {
|
||||
sortMode = mode;
|
||||
_updateSortBtns();
|
||||
runFilter();
|
||||
}
|
||||
|
||||
function _updateSortBtns() {
|
||||
document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
||||
const el = document.getElementById('sort-' + sortMode);
|
||||
if (el) el.classList.add('active');
|
||||
}
|
||||
|
||||
function _sortInPlace(arr) {
|
||||
if (sortMode === 'name-asc') { arr.sort((a,b) => a.name.localeCompare(b.name)); return; }
|
||||
if (sortMode === 'name-desc') { arr.sort((a,b) => b.name.localeCompare(a.name)); return; }
|
||||
if (sortMode === 'msgid') { arr.sort((a,b) => a.messageId - b.messageId); return; }
|
||||
// selection-first: 선택된 것을 앞으로
|
||||
arr.sort((a,b) => {
|
||||
const as = selectedSet.has(a.messageId+'|'+a.name) ? 0 : 1;
|
||||
const bs = selectedSet.has(b.messageId+'|'+b.name) ? 0 : 1;
|
||||
return as - bs;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 그래프 시작 (★ WebSocket 연결 없음 — 불필요)
|
||||
// ════════════════════════════════════════════════════════
|
||||
function startGraphing() {
|
||||
if (!selectedSigs.length) { showStatus('신호를 하나 이상 선택하세요!', 'error'); return; }
|
||||
saveState();
|
||||
const w=1400, h=900, l=(screen.width-w)/2, t=(screen.height-h)/2;
|
||||
window.open('/graph-view', 'CAN_Graph_Viewer',
|
||||
'width='+w+',height='+h+',left='+l+',top='+t+',resizable=yes,scrollbars=yes');
|
||||
showStatus('그래프 뷰어 열림 (신호 ' + selectedSigs.length + '개)', 'success');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 상태 메시지
|
||||
// ════════════════════════════════════════════════════════
|
||||
function showStatus(msg, type) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = msg;
|
||||
el.className = 'status ' + type;
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => el.className='status', 5000);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 드래그&드롭
|
||||
// ════════════════════════════════════════════════════════
|
||||
const ua = document.getElementById('upload-area');
|
||||
['dragenter','dragover','dragleave','drop'].forEach(ev =>
|
||||
ua.addEventListener(ev, e => { e.preventDefault(); e.stopPropagation(); }));
|
||||
['dragenter','dragover'].forEach(ev =>
|
||||
ua.addEventListener(ev, () => { ua.style.borderColor='var(--blue)'; }));
|
||||
['dragleave','drop'].forEach(ev =>
|
||||
ua.addEventListener(ev, () => { ua.style.borderColor='var(--border)'; }));
|
||||
ua.addEventListener('drop', e => {
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.name.endsWith('.dbc')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => parseDBCContent(ev.target.result, file.name);
|
||||
reader.readAsText(file);
|
||||
}
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 시작 (★ WebSocket 없음)
|
||||
// ════════════════════════════════════════════════════════
|
||||
window.addEventListener('load', () => {
|
||||
// 파일명 힌트 복원
|
||||
const fn = localStorage.getItem('dbc_filename');
|
||||
if (fn) {
|
||||
document.getElementById('dbc-status').textContent =
|
||||
fn + ' — 파일을 다시 업로드하거나 드롭하세요 (localStorage에는 신호 선택만 저장됨)';
|
||||
}
|
||||
// ★ DBC 없이도 이전 선택 신호 개수 표시
|
||||
const saved = localStorage.getItem('selected_signals');
|
||||
if (saved) {
|
||||
try {
|
||||
const cnt = JSON.parse(saved).length;
|
||||
if (cnt > 0) showStatus('이전 선택 ' + cnt + '개 — DBC 업로드 후 자동 복원됩니다.', 'success');
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
)rawliteral";
|
||||
|
||||
Reference in New Issue
Block a user