디자인 통일
This commit is contained in:
@@ -68,6 +68,17 @@
|
|||||||
// 변경 필요시: OSC_40MHz, OSC_4MHz10xPLL 등
|
// 변경 필요시: OSC_40MHz, OSC_4MHz10xPLL 등
|
||||||
#define CANFD_OSC ACAN2517FDSettings::OSC_20MHz
|
#define CANFD_OSC ACAN2517FDSettings::OSC_20MHz
|
||||||
|
|
||||||
|
// ── CAN SPI 클럭 설정 ──────────────────────────────────
|
||||||
|
// MCP2518FD 최대 SPI = 0.85 × Fosc
|
||||||
|
// OSC_20MHz → 최대 17 MHz
|
||||||
|
// OSC_40MHz → 최대 34 MHz
|
||||||
|
// 배선이 길거나 불안정하면 낮추세요 (최소 1 MHz)
|
||||||
|
// 5 MHz : 안전 (노이즈에 강함)
|
||||||
|
// 10 MHz : 권장 (안정성/속도 균형)
|
||||||
|
// 15 MHz : 고속 (짧은 배선에서)
|
||||||
|
// 17 MHz : 최대 (OSC_20MHz 기준)
|
||||||
|
#define CAN_SPI_CLOCK 10000000UL // ← 여기서 변경
|
||||||
|
|
||||||
// 최대 데이터 길이 (CAN FD = 64, Classic CAN = 8)
|
// 최대 데이터 길이 (CAN FD = 64, Classic CAN = 8)
|
||||||
#define CANFD_MAX_DATA 64
|
#define CANFD_MAX_DATA 64
|
||||||
|
|
||||||
@@ -404,8 +415,8 @@ void saveSettings() {
|
|||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
bool initCANFD() {
|
bool initCANFD() {
|
||||||
const CANFDPreset& p = speedPresets[speedPresetIdx];
|
const CANFDPreset& p = speedPresets[speedPresetIdx];
|
||||||
Serial.printf("CAN FD 초기화: %s (Listen:%s)\n",
|
Serial.printf("CAN FD 초기화: %s (Listen:%s, SPI:%luMHz)\n",
|
||||||
p.name, listenOnly ? "ON" : "OFF");
|
p.name, listenOnly ? "ON" : "OFF", CAN_SPI_CLOCK / 1000000UL);
|
||||||
|
|
||||||
ACAN2517FDSettings settings(CANFD_OSC, p.arbBPS, p.factor);
|
ACAN2517FDSettings settings(CANFD_OSC, p.arbBPS, p.factor);
|
||||||
|
|
||||||
@@ -441,6 +452,7 @@ bool initCANFD() {
|
|||||||
gSPI.end();
|
gSPI.end();
|
||||||
delay(50);
|
delay(50);
|
||||||
gSPI.begin(PIN_CAN_SCK, PIN_CAN_MISO, PIN_CAN_MOSI, PIN_CAN_CS);
|
gSPI.begin(PIN_CAN_SCK, PIN_CAN_MISO, PIN_CAN_MOSI, PIN_CAN_CS);
|
||||||
|
gSPI.setFrequency(CAN_SPI_CLOCK);
|
||||||
digitalWrite(PIN_CAN_CS, HIGH);
|
digitalWrite(PIN_CAN_CS, HIGH);
|
||||||
delay(100);
|
delay(100);
|
||||||
}
|
}
|
||||||
@@ -1138,9 +1150,11 @@ void setup() {
|
|||||||
// SPI + CAN FD 초기화
|
// SPI + CAN FD 초기화
|
||||||
// ★ CS는 라이브러리가 직접 관리 → SPI.begin에 CS 미전달 (HW SS 충돌 방지)
|
// ★ CS는 라이브러리가 직접 관리 → SPI.begin에 CS 미전달 (HW SS 충돌 방지)
|
||||||
gSPI.begin(PIN_CAN_SCK, PIN_CAN_MISO, PIN_CAN_MOSI, -1);
|
gSPI.begin(PIN_CAN_SCK, PIN_CAN_MISO, PIN_CAN_MOSI, -1);
|
||||||
|
gSPI.setFrequency(CAN_SPI_CLOCK); // ★ SPI 클럭 설정
|
||||||
pinMode(PIN_CAN_CS, OUTPUT);
|
pinMode(PIN_CAN_CS, OUTPUT);
|
||||||
digitalWrite(PIN_CAN_CS, HIGH);
|
digitalWrite(PIN_CAN_CS, HIGH);
|
||||||
delay(50); // SPI 버스 안정화 대기
|
delay(50); // SPI 버스 안정화 대기
|
||||||
|
Serial.printf("SPI 클럭: %lu MHz\n", CAN_SPI_CLOCK / 1000000UL);
|
||||||
|
|
||||||
if (!initCANFD()) {
|
if (!initCANFD()) {
|
||||||
Serial.println("⚠ CAN FD 초기화 실패 → 설정 페이지에서 속도 변경 후 재초기화 하세요");
|
Serial.println("⚠ CAN FD 초기화 실패 → 설정 페이지에서 속도 변경 후 재초기화 하세요");
|
||||||
|
|||||||
614
graph.h
614
graph.h
@@ -1,156 +1,498 @@
|
|||||||
#ifndef GRAPH_H
|
#ifndef GRAPH_H
|
||||||
#define GRAPH_H
|
#define GRAPH_H
|
||||||
|
|
||||||
const char graph_html[] PROGMEM = R"rawliteral(
|
const char graph_html[] PROGMEM = R"rawliteral(
|
||||||
<!DOCTYPE html><html lang="ko"><head>
|
<!DOCTYPE html><html><head>
|
||||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||||
<title>그래프 - CANFD Logger</title>
|
<title>CAN FD Signal Selector</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
:root{--bg:#0e1117;--panel:#161b24;--card:#1c2230;--border:#2d3748;--accent:#43cea2;--blue:#58a6ff;--red:#f85149;--yellow:#e3b341;--text:#e6edf3;--muted:#8b949e;--r:8px;}
|
||||||
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
|
*{margin:0;padding:0;box-sizing:border-box;}html,body{min-height:100%;}
|
||||||
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden;font-size:14px;}
|
||||||
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
|
.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;}
|
||||||
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
|
.header h1{font-size:1.0em;font-weight:700;color:var(--accent);}
|
||||||
.title{color:#e6edf3;font-weight:700;font-size:15px}
|
.header p{font-size:.78em;color:var(--muted);margin:0;}
|
||||||
.container{padding:12px}
|
.header-spacer{flex:1;}
|
||||||
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:10px}
|
nav{background:var(--panel);border-bottom:1px solid var(--border);display:flex;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch;}
|
||||||
.card h3{font-size:12px;color:#8b949e;margin-bottom:12px;text-transform:uppercase}
|
nav::-webkit-scrollbar{display:none;}
|
||||||
input[type=text],select{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
|
nav .nav-title{color:var(--accent);font-weight:700;font-size:1.0em;padding:10px 14px;white-space:nowrap;border-bottom:2px solid transparent;}
|
||||||
.btn{padding:5px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600}
|
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;}
|
||||||
.btn-blue{background:#1f6feb;color:#fff} .btn-green{background:#238636;color:#fff}
|
nav a:hover{color:var(--text);}
|
||||||
.btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
|
nav a.active{color:var(--accent);border-bottom-color:var(--accent);}
|
||||||
.row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:8px}
|
.content{padding:12px;}
|
||||||
.field{margin-bottom:4px} label{display:block;font-size:11px;color:#8b949e;margin-bottom:3px}
|
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);}
|
||||||
canvas{width:100%;height:300px;background:#0d1117;border:1px solid #30363d;border-radius:6px}
|
.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;}
|
||||||
.sig-list{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
|
.btn:hover,button:hover{border-color:var(--accent);color:var(--accent);}
|
||||||
.sig-chip{background:#21262d;border:1px solid #30363d;border-radius:12px;padding:3px 10px;font-size:11px;display:flex;align-items:center;gap:6px}
|
.btn:active,button:active{transform:scale(.97);}
|
||||||
.sig-color{width:10px;height:10px;border-radius:50%;display:inline-block}
|
.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>
|
</style>
|
||||||
</head><body>
|
</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>
|
<nav>
|
||||||
<span class="title">⚡ CANFD Logger</span>
|
<span class="nav-title">⚡ CANFD Logger</span>
|
||||||
<a href="/">대시보드</a><a href="/transmit">송신</a>
|
<a href="/">대시보드</a>
|
||||||
<a href="/graph" class="active">그래프</a><a href="/settings">설정</a>
|
<a href="/transmit">송신</a>
|
||||||
|
<a href="/graph" class="active">그래프</a>
|
||||||
|
<a href="/graph-view">그래프 뷰어</a>
|
||||||
|
<a href="/settings">설정</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="content">
|
||||||
<div class="card">
|
<div id="status" class="status"></div>
|
||||||
<h3>실시간 신호 그래프 (CAN FD)</h3>
|
<h2>DBC 파일 업로드</h2>
|
||||||
<div class="row">
|
<div class="upload-area" id="upload-area" onclick="document.getElementById('dbc-file').click()">
|
||||||
<div class="field"><label>CAN ID (hex)</label><input type="text" id="sigId" value="0x123" style="width:90px"></div>
|
<input type="file" id="dbc-file" accept=".dbc" onchange="loadDBCFile(event)">
|
||||||
<div class="field"><label>시작 비트</label><input type="text" id="sigStart" value="0" style="width:60px"></div>
|
<p style="font-size:1.05em;margin-bottom:6px;">클릭 또는 드래그&드롭으로 DBC 업로드</p>
|
||||||
<div class="field"><label>비트 길이</label><input type="text" id="sigLen" value="8" style="width:60px"></div>
|
<p id="dbc-status" style="color:var(--muted);font-size:.85em;">파일 없음 — localStorage 복원 가능</p>
|
||||||
<div class="field"><label>배율</label><input type="text" id="sigScale" value="1" style="width:60px"></div>
|
</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 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>
|
||||||
<div class="sig-list" id="sigList"></div>
|
<div class="controls">
|
||||||
<canvas id="chart"></canvas>
|
<button class="btn btn-success" onclick="startGraphing()">▶ 실시간 그래프 시작</button>
|
||||||
<div style="margin-top:8px;display:flex;gap:8px">
|
<button class="btn btn-primary" onclick="clearSelection()">선택 초기화</button>
|
||||||
<button class="btn btn-gray" onclick="clearSignals()">신호 초기화</button>
|
</div>
|
||||||
<span style="font-size:11px;color:#8b949e;margin-top:8px">최대 8개 신호, 500 포인트 표시</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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 dbcData = {messages:{}, valueTables:{}};
|
||||||
let l=parseInt(document.getElementById('sigLen').value);
|
let allSignals = [];
|
||||||
let sc=parseFloat(document.getElementById('sigScale').value)||1;
|
let filteredSigs = []; // 현재 검색/정렬된 전체 결과
|
||||||
let nm=document.getElementById('sigName').value||('Signal '+(signals.length+1));
|
let shownCount = 0; // 현재 DOM에 그려진 수
|
||||||
let color=COLORS[signals.length%COLORS.length];
|
let selectedSet = new Set(); // ★ O(1) 선택 확인 ("msgId|name" 키)
|
||||||
signals.push({id,startBit:s,bitLen:l,scale:sc,name:nm,color,pts:[],lastVal:null});
|
let selectedSigs = []; // 순서 보존용 배열
|
||||||
renderChips(); draw();
|
let sortMode = 'selection';
|
||||||
}
|
let searchQuery = '';
|
||||||
|
let filterTimer = null; // ★ 디바운스 타이머
|
||||||
|
let hlRegex = null; // ★ 검색어 regex 캐시 (매 신호마다 생성 방지)
|
||||||
|
let dbcFilename = '';
|
||||||
|
|
||||||
function clearSignals(){ signals=[]; renderChips(); draw(); }
|
// ════════════════════════════════════════════════════════
|
||||||
|
// DBC 로드
|
||||||
function renderChips(){
|
// ════════════════════════════════════════════════════════
|
||||||
let html='';
|
function loadDBCFile(event) {
|
||||||
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>`; });
|
const file = event.target.files[0]; if (!file) return;
|
||||||
document.getElementById('sigList').innerHTML=html;
|
showStatus('DBC 파싱 중...', 'success');
|
||||||
}
|
const reader = new FileReader();
|
||||||
function removeSignal(i){ signals.splice(i,1); renderChips(); draw(); }
|
reader.onload = e => {
|
||||||
|
parseDBCContent(e.target.result, file.name);
|
||||||
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){}
|
|
||||||
};
|
};
|
||||||
|
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>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
)rawliteral";
|
)rawliteral";
|
||||||
|
|||||||
919
graph_viewer.h
919
graph_viewer.h
@@ -1,222 +1,759 @@
|
|||||||
#ifndef GRAPH_VIEWER_H
|
#ifndef GRAPH_VIEWER_H
|
||||||
#define GRAPH_VIEWER_H
|
#define GRAPH_VIEWER_H
|
||||||
|
|
||||||
|
|
||||||
const char graph_viewer_html[] PROGMEM = R"rawliteral(
|
const char graph_viewer_html[] PROGMEM = R"rawliteral(
|
||||||
<!DOCTYPE html><html lang="ko"><head>
|
<!DOCTYPE html><html><head>
|
||||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
<title>그래프 뷰어 - CANFD Logger</title>
|
<title>CAN FD Signal Graph</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
:root{--bg:#0e1117;--panel:#161b24;--card:#1c2230;--border:#2d3748;--accent:#43cea2;--accent2:#38ef7d;--blue:#58a6ff;--red:#f85149;--yellow:#e3b341;--text:#e6edf3;--muted:#8b949e;--r:10px;}
|
||||||
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
|
*{margin:0;padding:0;box-sizing:border-box;}html,body{height:100%;}
|
||||||
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden;font-size:14px;}
|
||||||
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
|
.header{background:linear-gradient(135deg,#1a2744 0%,#1e1a3a 100%);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
|
||||||
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
|
.header h1{font-size:1.05em;font-weight:700;color:var(--accent);white-space:nowrap;}
|
||||||
.title{color:#e6edf3;font-weight:700;font-size:15px}
|
.header p{font-size:.8em;color:var(--muted);margin:0;}
|
||||||
.container{padding:12px;max-width:1000px}
|
.header-spacer{flex:1;}
|
||||||
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:10px}
|
nav{background:var(--panel);border-bottom:1px solid var(--border);display:flex;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch;}
|
||||||
.card h3{font-size:12px;color:#8b949e;margin-bottom:12px;text-transform:uppercase}
|
nav::-webkit-scrollbar{display:none;}
|
||||||
.btn{padding:5px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600}
|
nav .nav-title{color:var(--accent);font-weight:700;font-size:1.0em;padding:10px 14px;white-space:nowrap;border-bottom:2px solid transparent;}
|
||||||
.btn-blue{background:#1f6feb;color:#fff} .btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
|
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;}
|
||||||
input[type=text],input[type=file]{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
|
nav a:hover{color:var(--text);}
|
||||||
.row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:8px}
|
nav a.active{color:var(--accent);border-bottom-color:var(--accent);}
|
||||||
.field{margin-bottom:4px} label{display:block;font-size:11px;color:#8b949e;margin-bottom:3px}
|
.controls{background:var(--panel);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
|
||||||
canvas{width:100%;height:300px;background:#0d1117;border:1px solid #30363d;border-radius:6px}
|
.control-group{display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
|
||||||
.info{font-size:11px;color:#8b949e;margin-top:6px}
|
.control-label{font-size:.72em;color:var(--muted);white-space:nowrap;padding-right:2px;}
|
||||||
.stat{display:inline-block;margin-right:16px;color:#8b949e}
|
.ctrl-sep{width:1px;height:20px;background:var(--border);margin:0 4px;}
|
||||||
.stat b{color:#e6edf3}
|
.btn{padding:5px 11px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--muted);font-size:.78em;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap;font-family:inherit;-webkit-tap-highlight-color:transparent;touch-action:manipulation;}
|
||||||
|
.btn:hover{border-color:var(--accent);color:var(--accent);}
|
||||||
|
.btn:active{transform:scale(.96);}
|
||||||
|
.btn.active{background:rgba(67,206,162,.15);border-color:var(--accent);color:var(--accent);}
|
||||||
|
.btn-success{border-color:#3fb950;color:#3fb950;}.btn-success.active,.btn-success:hover{background:rgba(63,185,80,.15);}
|
||||||
|
.btn-danger{border-color:var(--red);color:var(--red);}.btn-danger:hover{background:rgba(248,81,73,.12);}
|
||||||
|
.btn-info{border-color:var(--blue);color:var(--blue);}.btn-info.active,.btn-info:hover{background:rgba(88,166,255,.15);}
|
||||||
|
.btn-warning{border-color:var(--yellow);color:var(--yellow);}.btn-warning.active,.btn-warning:hover{background:rgba(227,179,65,.15);}
|
||||||
|
.stats-bar{display:flex;flex-wrap:wrap;gap:14px;padding:5px 14px;background:var(--bg);border-bottom:1px solid var(--border);font-size:.75em;color:var(--muted);}
|
||||||
|
.stats-bar strong{color:var(--accent);margin-left:3px;}
|
||||||
|
.status{padding:4px 10px;border-radius:20px;font-size:.72em;font-weight:600;background:rgba(67,206,162,.12);color:var(--accent);border:1px solid rgba(67,206,162,.3);transition:all .3s;white-space:nowrap;}
|
||||||
|
.status.disconnected{background:rgba(248,81,73,.12);color:var(--red);border-color:rgba(248,81,73,.3);}
|
||||||
|
.graphs{padding:10px;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,440px),1fr));gap:10px;}
|
||||||
|
.graph-container{background:var(--card);border-radius:var(--r);border:1px solid var(--border);overflow:hidden;}
|
||||||
|
.graph-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border);background:rgba(67,206,162,.05);gap:8px;}
|
||||||
|
.graph-title{font-size:.82em;font-weight:700;color:var(--accent);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
.graph-value{font-size:1em;font-weight:700;color:var(--accent2);font-family:'Courier New',monospace;white-space:nowrap;flex-shrink:0;}
|
||||||
|
canvas{display:block;width:100%;height:200px;border:none;}
|
||||||
|
.fd-tag{display:inline-block;padding:1px 5px;border-radius:3px;font-size:.7em;font-weight:700;background:#2d1b69;color:#a371f7;margin-left:4px;}
|
||||||
|
@media(max-width:480px){.controls{padding:6px 8px;gap:5px;}.ctrl-sep{display:none;}.btn{padding:6px 9px;font-size:.8em;}.header h1{font-size:.95em;}canvas{height:175px;}.stats-bar{gap:8px;font-size:.7em;}}
|
||||||
|
@media(min-width:768px){canvas{height:230px;}}
|
||||||
|
@media(min-width:1200px){canvas{height:260px;}}
|
||||||
</style>
|
</style>
|
||||||
</head><body>
|
</head><body>
|
||||||
|
<div class="header">
|
||||||
|
<div><h1>CAN FD Signal Graph</h1><p>Viewing <span id="graph-count">0</span> signals</p></div>
|
||||||
|
<div class="header-spacer"></div>
|
||||||
|
<div class="status" id="status-pill">Connecting...</div>
|
||||||
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
<span class="title">⚡ CANFD Logger</span>
|
<span class="nav-title">⚡ CANFD Logger</span>
|
||||||
<a href="/">대시보드</a><a href="/transmit">송신</a>
|
<a href="/">대시보드</a>
|
||||||
<a href="/graph">그래프</a><a href="/graph-view" class="active">뷰어</a>
|
<a href="/transmit">송신</a>
|
||||||
|
<a href="/graph">그래프</a>
|
||||||
|
<a href="/graph-view" class="active">그래프 뷰어</a>
|
||||||
<a href="/settings">설정</a>
|
<a href="/settings">설정</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="controls">
|
||||||
<div class="card">
|
<div class="control-group">
|
||||||
<h3>BIN 파일 분석 뷰어 (CAN FD)</h3>
|
<button class="btn btn-success active" id="btn-start" onclick="startGraphing()">▶ Start</button>
|
||||||
<div class="row">
|
<button class="btn btn-danger" id="btn-stop" onclick="stopGraphing()">■ Stop</button>
|
||||||
<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>
|
||||||
|
<div class="ctrl-sep"></div>
|
||||||
<div class="card" id="analyzeCard" style="display:none">
|
<div class="control-group">
|
||||||
<h3>신호 설정</h3>
|
<span class="control-label">Scale:</span>
|
||||||
<div class="row">
|
<button class="btn btn-info active" id="btn-index-mode" onclick="setScaleMode('index')">Index</button>
|
||||||
<div class="field"><label>CAN ID (hex)</label><input type="text" id="vSigId" value="0x123" style="width:90px"></div>
|
<button class="btn btn-info" id="btn-time-mode" onclick="setScaleMode('time')">Time</button>
|
||||||
<div class="field"><label>시작 비트</label><input type="text" id="vSigStart" value="0" style="width:60px"></div>
|
</div>
|
||||||
<div class="field"><label>비트 길이</label><input type="text" id="vSigLen" value="8" style="width:60px"></div>
|
<div class="ctrl-sep"></div>
|
||||||
<div class="field"><label>배율</label><input type="text" id="vSigScale" value="1" style="width:60px"></div>
|
<div class="control-group">
|
||||||
<div class="field"><label>이름</label><input type="text" id="vSigName" value="Signal 1" style="width:90px"></div>
|
<span class="control-label">Range:</span>
|
||||||
<div class="field" style="margin-bottom:4px"><button class="btn btn-blue" onclick="addSig()">+ 추가</button></div>
|
<button class="btn btn-warning active" id="btn-range-10s" onclick="setRangeMode('10s')">10s</button>
|
||||||
</div>
|
<button class="btn btn-warning" id="btn-range-30s" onclick="setRangeMode('30s')">30s</button>
|
||||||
<div id="vSigList" style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0"></div>
|
<button class="btn btn-warning" id="btn-range-all" onclick="setRangeMode('all')">All</button>
|
||||||
<canvas id="vChart"></canvas>
|
</div>
|
||||||
<div style="margin-top:8px">
|
<div class="ctrl-sep"></div>
|
||||||
<span class="stat">시간 범위: <b id="timeRange">-</b></span>
|
<div class="control-group">
|
||||||
<span class="stat">총 메시지: <b id="totalMsgs">0</b></span>
|
<span class="control-label">Plot:</span>
|
||||||
<span class="stat">FD 메시지: <b id="fdMsgs">0</b></span>
|
<button class="btn btn-info active" id="btn-plot-line" onclick="setPlotMode('line')">Line</button>
|
||||||
</div>
|
<button class="btn btn-info" id="btn-plot-scatter" onclick="setPlotMode('scatter')">Dot</button>
|
||||||
<div style="margin-top:8px;display:flex;gap:8px">
|
</div>
|
||||||
<button class="btn btn-gray" onclick="clearSigs()">신호 초기화</button>
|
<div class="ctrl-sep"></div>
|
||||||
<button class="btn btn-blue" onclick="exportCSV()">CSV 내보내기</button>
|
<div class="control-group">
|
||||||
</div>
|
<span class="control-label">Sort:</span>
|
||||||
|
<button class="btn btn-info active" id="btn-sort-selection" onclick="setSortMode('selection')">Order</button>
|
||||||
|
<button class="btn btn-info" id="btn-sort-name-asc" onclick="setSortMode('name-asc')">A→Z</button>
|
||||||
|
<button class="btn btn-info" id="btn-sort-name-desc" onclick="setSortMode('name-desc')">Z→A</button>
|
||||||
|
</div>
|
||||||
|
<div class="ctrl-sep"></div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button class="btn btn-success" onclick="downloadCSV()">⬇ CSV</button>
|
||||||
|
<button class="btn btn-danger" onclick="clearData()">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stats-bar">
|
||||||
|
<span>Points:<strong id="stat-pts">0</strong></span>
|
||||||
|
<span>Time:<strong id="stat-time">0s</strong></span>
|
||||||
|
<span>Msgs:<strong id="stat-msgs">0</strong></span>
|
||||||
|
<span>FPS:<strong id="stat-fps">0</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="graphs" id="graphs"></div>
|
||||||
<script>
|
<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=[];
|
// ════════════════════════════════════════════════════════
|
||||||
|
const DLC_LEN=[0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64];
|
||||||
|
const MAX_PTS=300; // 차트당 최대 데이터 포인트
|
||||||
|
|
||||||
function parseBin(buf){
|
const COLORS=[
|
||||||
records=[];
|
{line:'#FF6384',fill:'rgba(255,99,132,.18)'},{line:'#36A2EB',fill:'rgba(54,162,235,.18)'},
|
||||||
let n=Math.floor(buf.byteLength/RECORD_SIZE);
|
{line:'#FFCE56',fill:'rgba(255,206,86,.18)'},{line:'#4BC0C0',fill:'rgba(75,192,192,.18)'},
|
||||||
let dv=new DataView(buf);
|
{line:'#9966FF',fill:'rgba(153,102,255,.18)'},{line:'#FF9F40',fill:'rgba(255,159,64,.18)'},
|
||||||
for(let i=0;i<n;i++){
|
{line:'#E74C3C',fill:'rgba(231,76,60,.18)'}, {line:'#3498DB',fill:'rgba(52,152,219,.18)'},
|
||||||
let off=i*RECORD_SIZE;
|
{line:'#2ECC71',fill:'rgba(46,204,113,.18)'},{line:'#F39C12',fill:'rgba(243,156,18,.18)'},
|
||||||
let tsLo=dv.getUint32(off,true), tsHi=dv.getUint32(off+4,true);
|
{line:'#9B59B6',fill:'rgba(155,89,182,.18)'},{line:'#1ABC9C',fill:'rgba(26,188,156,.18)'},
|
||||||
let ts=tsHi*0x100000000+tsLo; // uint64 → JS Number (μs, precision ok for logging)
|
{line:'#E67E22',fill:'rgba(230,126,34,.18)'},{line:'#95A5A6',fill:'rgba(149,165,166,.18)'},
|
||||||
let id=dv.getUint32(off+8,true);
|
{line:'#58a6ff',fill:'rgba(88,166,255,.18)'},{line:'#43cea2',fill:'rgba(67,206,162,.18)'},
|
||||||
let dlc=dv.getUint8(off+12);
|
{line:'#bc8cff',fill:'rgba(188,140,255,.18)'},{line:'#ffa657',fill:'rgba(255,166,87,.18)'},
|
||||||
let len=dv.getUint8(off+13);
|
{line:'#f85149',fill:'rgba(248,81,73,.18)'}, {line:'#e3b341',fill:'rgba(227,179,65,.18)'}
|
||||||
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 selectedSignals=[];
|
||||||
|
let sortMode='selection';
|
||||||
|
let scaleMode='index';
|
||||||
|
let rangeMode='10s';
|
||||||
|
let plotMode='line';
|
||||||
|
let graphing=false;
|
||||||
|
let startTime=0;
|
||||||
|
let totalMsgReceived=0;
|
||||||
|
let lastCanCounts={};
|
||||||
|
let lastSignalTimes={};
|
||||||
|
|
||||||
|
// ── DOM 캐시 (핫패스에서 getElementById 반복 호출 방지) ─
|
||||||
|
let valueEls=[]; // valueEls[idx] = DOM element
|
||||||
|
|
||||||
|
// ── 차트 배열 ──────────────────────────────────────────
|
||||||
|
let charts=[]; // SimpleChart[]
|
||||||
|
|
||||||
|
// ── RAF 렌더 루프 제어 ──────────────────────────────────
|
||||||
|
let rafId=null;
|
||||||
|
let dirty=false; // 새 데이터가 들어왔는지 (dirty=true → 다음 프레임에 draw)
|
||||||
|
let lastFpsTime=0;
|
||||||
|
let fpsCount=0;
|
||||||
|
let fps=0;
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// ★ SimpleChart — 원형 버퍼(circular buffer) 기반
|
||||||
|
// Array.shift() O(n) 제거 → head 포인터만 이동 O(1)
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
class SimpleChart {
|
||||||
|
constructor(canvas, signal, colorIndex) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d', {alpha:false}); // alpha:false → 합성 생략
|
||||||
|
this.signal = signal;
|
||||||
|
this.colors = COLORS[colorIndex % COLORS.length];
|
||||||
|
this.currentValue = NaN;
|
||||||
|
this.currentText = '';
|
||||||
|
|
||||||
|
// 원형 버퍼: data[head % MAX_PTS] 에 쓰고, head만 증가
|
||||||
|
this._buf = new Float64Array(MAX_PTS); // 64bit float 타입 배열 → GC 없음
|
||||||
|
this._tbuf = new Float64Array(MAX_PTS); // 시간 버퍼
|
||||||
|
this._head = 0; // 다음에 쓸 인덱스 (절대값, MAX_PTS로 mod)
|
||||||
|
this._count = 0; // 실제 저장된 포인트 수 (최대 MAX_PTS)
|
||||||
|
|
||||||
|
this.width = 0; this.height = 0;
|
||||||
|
this._syncCanvas();
|
||||||
|
// ★ resize 리스너: 인스턴스별 등록 금지 → 전역 리스너 1개로 처리(아래)
|
||||||
}
|
}
|
||||||
let total=records.length;
|
|
||||||
let fdCnt=records.filter(r=>(r.flags&0x20)!==0).length;
|
// canvas 물리 픽셀 동기화
|
||||||
document.getElementById('totalMsgs').textContent=total.toLocaleString();
|
_syncCanvas() {
|
||||||
document.getElementById('fdMsgs').textContent=fdCnt.toLocaleString();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
if(total>0){
|
if (!rect.width) return;
|
||||||
let t0=records[0].ts, t1=records[total-1].ts;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
document.getElementById('timeRange').textContent=((t1-t0)/1e6).toFixed(3)+'s';
|
this.canvas.width = rect.width * dpr;
|
||||||
document.getElementById('fileInfo').textContent=
|
this.canvas.height = rect.height * dpr;
|
||||||
`총 ${total.toLocaleString()} 메시지 | FD: ${fdCnt.toLocaleString()} | 시간: ${((t1-t0)/1e6).toFixed(3)}s`;
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
this.width = rect.width;
|
||||||
|
this.height = rect.height;
|
||||||
}
|
}
|
||||||
document.getElementById('analyzeCard').style.display='block';
|
|
||||||
draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadBin(){
|
// O(1) 삽입
|
||||||
let f=document.getElementById('binFile').files[0]; if(!f) return;
|
push(value, time) {
|
||||||
document.getElementById('fileInfo').textContent='읽는 중...';
|
const i = this._head % MAX_PTS;
|
||||||
let r=new FileReader();
|
this._buf[i] = value;
|
||||||
r.onload=e=>parseBin(e.target.result);
|
this._tbuf[i] = time;
|
||||||
r.readAsArrayBuffer(f);
|
this._head++;
|
||||||
}
|
if (this._count < MAX_PTS) this._count++;
|
||||||
|
|
||||||
function loadUrl(){
|
this.currentValue = value;
|
||||||
let fn=document.getElementById('urlInput').value.trim(); if(!fn) return;
|
if (this.signal.valueTable && this.signal.valueTable[value] !== undefined)
|
||||||
document.getElementById('fileInfo').textContent='다운로드 중...';
|
this.currentText = this.signal.valueTable[value];
|
||||||
fetch('/download?file='+fn).then(r=>r.arrayBuffer()).then(parseBin)
|
else
|
||||||
.catch(e=>{ document.getElementById('fileInfo').textContent='다운로드 실패: '+e; });
|
this.currentText = '';
|
||||||
}
|
|
||||||
|
|
||||||
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개');
|
_getDisplay() {
|
||||||
let id=parseInt(document.getElementById('vSigId').value,16);
|
if (!this._count) return null;
|
||||||
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');
|
// all 모드 → 전체
|
||||||
const ctx=cv.getContext('2d');
|
let n = this._count;
|
||||||
function draw(){
|
if (rangeMode !== 'all' && scaleMode === 'index') {
|
||||||
let W=cv.offsetWidth, H=cv.offsetHeight;
|
const N = rangeMode === '30s' ? 150 : 60;
|
||||||
cv.width=W; cv.height=H;
|
n = Math.min(n, N);
|
||||||
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;
|
const data = new Float64Array(n);
|
||||||
|
const times = new Float64Array(n);
|
||||||
|
const start = this._head - n;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const j = (start + i) & (MAX_PTS - 1); // MAX_PTS가 2의 거듭제곱이면 mod 불필요
|
||||||
|
// MAX_PTS=300은 2의 거듭제곱이 아니므로 % 사용
|
||||||
|
const k = ((start + i) % MAX_PTS + MAX_PTS) % MAX_PTS;
|
||||||
|
data[i] = this._buf[k];
|
||||||
|
times[i] = this._tbuf[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
// time 모드에서 시간 윈도우 필터
|
||||||
|
if (rangeMode !== 'all' && scaleMode === 'time') {
|
||||||
|
const wSec = rangeMode === '30s' ? 30 : 10;
|
||||||
|
const latest = times[times.length - 1];
|
||||||
|
const cutoff = latest - wSec;
|
||||||
|
let fi = 0;
|
||||||
|
while (fi < times.length && times[fi] < cutoff) fi++;
|
||||||
|
if (fi > 0) return { data: data.subarray(fi), times: times.subarray(fi) };
|
||||||
|
}
|
||||||
|
return { data, times };
|
||||||
}
|
}
|
||||||
// 각 신호별 포인트 계산
|
|
||||||
let seriesList=signals.map(s=>{
|
draw() {
|
||||||
let pts=records.filter(r=>r.id===s.id).map(r=>({x:r.ts,y:extractBits(r.data,s.startBit,s.bitLen)*s.scale}));
|
const ctx = this.ctx, W = this.width, H = this.height;
|
||||||
return {s,pts};
|
if (!W || !H) return;
|
||||||
}).filter(s=>s.pts.length>0);
|
const PAD_L=45, PAD_T=8, PAD_B=22, PAD_R=8;
|
||||||
if(!seriesList.length) return;
|
const gW = W-PAD_L-PAD_R, gH = H-PAD_T-PAD_B;
|
||||||
let allPts=seriesList.flatMap(s=>s.pts);
|
|
||||||
let minX=Math.min(...allPts.map(p=>p.x)), maxX=Math.max(...allPts.map(p=>p.x));
|
ctx.fillStyle = '#0e1117';
|
||||||
let minY=Math.min(...allPts.map(p=>p.y)), maxY=Math.max(...allPts.map(p=>p.y));
|
ctx.fillRect(0, 0, W, H);
|
||||||
if(minY===maxY){minY-=1;maxY+=1;}
|
|
||||||
let pad=35;
|
if (!this._count) {
|
||||||
function toX(x){return pad+(x-minX)/(maxX-minX||1)*(W-pad*2);}
|
ctx.fillStyle = '#374151';
|
||||||
function toY(y){return H-pad-(y-minY)/(maxY-minY)*(H-pad*2);}
|
ctx.font = '12px monospace';
|
||||||
// 격자
|
ctx.textAlign = 'center';
|
||||||
ctx.strokeStyle='#21262d';ctx.lineWidth=1;
|
ctx.fillText('Waiting for data...', W/2, H/2);
|
||||||
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();}
|
return;
|
||||||
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축 레이블
|
const disp = this._getDisplay();
|
||||||
ctx.textAlign='center';
|
if (!disp) return;
|
||||||
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);}
|
const { data: dd, times: dt } = disp;
|
||||||
// 신호 그리기
|
const n = dd.length;
|
||||||
seriesList.forEach(({s,pts})=>{
|
if (!n) return;
|
||||||
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);});
|
// ★ Math.min/max(...spread) 대신 루프 → 스택 오버플로우 방지
|
||||||
|
let minV = dd[0], maxV = dd[0];
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
if (dd[i] < minV) minV = dd[i];
|
||||||
|
if (dd[i] > maxV) maxV = dd[i];
|
||||||
|
}
|
||||||
|
const range = maxV - minV || 1;
|
||||||
|
|
||||||
|
// Y 그리드
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const y = PAD_T + (gH/4)*i;
|
||||||
|
ctx.strokeStyle = (i===4) ? '#2d3748' : '#1a2035';
|
||||||
|
ctx.beginPath(); ctx.moveTo(PAD_L, y); ctx.lineTo(PAD_L+gW, y); ctx.stroke();
|
||||||
|
const v = maxV - (range/4)*i;
|
||||||
|
let lab;
|
||||||
|
if (this.signal.valueTable) {
|
||||||
|
const rv = Math.round(v);
|
||||||
|
lab = (this.signal.valueTable[rv] !== undefined)
|
||||||
|
? String(this.signal.valueTable[rv]).substring(0, 7)
|
||||||
|
: v.toFixed(1);
|
||||||
|
} else {
|
||||||
|
lab = Math.abs(v) < 100 ? v.toFixed(2) : v.toFixed(0);
|
||||||
|
}
|
||||||
|
ctx.fillStyle='#6b7280'; ctx.font='9px monospace'; ctx.textAlign='right';
|
||||||
|
ctx.fillText(lab, PAD_L-3, y+3);
|
||||||
|
}
|
||||||
|
// X 그리드
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const x = PAD_L + (gW/4)*i;
|
||||||
|
ctx.strokeStyle='#1a2035'; ctx.lineWidth=0.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, PAD_T); ctx.lineTo(x, PAD_T+gH); ctx.stroke();
|
||||||
|
}
|
||||||
|
// 축선
|
||||||
|
ctx.strokeStyle='#2d3748'; ctx.lineWidth=1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(PAD_L, PAD_T); ctx.lineTo(PAD_L, PAD_T+gH); ctx.lineTo(PAD_L+gW, PAD_T+gH);
|
||||||
ctx.stroke();
|
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);
|
let minTime=0, timeRange=1;
|
||||||
});
|
if (scaleMode==='time' && n>1) {
|
||||||
|
minTime=dt[0]; timeRange=dt[n-1]-minTime||1;
|
||||||
|
}
|
||||||
|
const xOf = i => {
|
||||||
|
if (scaleMode==='time' && n>1) return PAD_L+((dt[i]-minTime)/timeRange)*gW;
|
||||||
|
return PAD_L + (n>1 ? (i/(n-1))*gW : 0);
|
||||||
|
};
|
||||||
|
const yOf = v => PAD_T+gH - ((v-minV)/range)*gH;
|
||||||
|
const lc = this.colors.line;
|
||||||
|
|
||||||
|
// Fill
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(xOf(0), PAD_T+gH);
|
||||||
|
for (let i=0; i<n; i++) ctx.lineTo(xOf(i), yOf(dd[i]));
|
||||||
|
ctx.lineTo(xOf(n-1), PAD_T+gH);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = this.colors.fill;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Line
|
||||||
|
if (plotMode==='line' && n>1) {
|
||||||
|
ctx.strokeStyle=lc; ctx.lineWidth=1.8; ctx.lineJoin='round';
|
||||||
|
ctx.beginPath(); ctx.moveTo(xOf(0), yOf(dd[0]));
|
||||||
|
for (let i=1; i<n; i++) ctx.lineTo(xOf(i), yOf(dd[i]));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// 점 (마지막만 or scatter)
|
||||||
|
for (let i=0; i<n; i++) {
|
||||||
|
const isLast = (i===n-1);
|
||||||
|
if (plotMode==='scatter' || isLast) {
|
||||||
|
const r = isLast ? 4.5 : 2.5;
|
||||||
|
ctx.fillStyle = isLast ? '#ffffff' : lc;
|
||||||
|
ctx.strokeStyle = lc;
|
||||||
|
ctx.lineWidth = isLast ? 1.5 : 0;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(xOf(i), yOf(dd[i]), r, 0, Math.PI*2);
|
||||||
|
ctx.fill();
|
||||||
|
if (isLast) ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// X 라벨
|
||||||
|
ctx.fillStyle='#6b7280'; ctx.font='9px monospace'; ctx.textAlign='center';
|
||||||
|
ctx.fillText(dt[0].toFixed(1)+'s', PAD_L, PAD_T+gH+16);
|
||||||
|
ctx.fillText(dt[n-1].toFixed(1)+'s', PAD_L+gW, PAD_T+gH+16);
|
||||||
|
if (n>4) {
|
||||||
|
const mid=Math.floor(n/2);
|
||||||
|
ctx.fillText(dt[mid].toFixed(1)+'s', PAD_L+gW*.5, PAD_T+gH+16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this._head=0; this._count=0;
|
||||||
|
this.currentValue=NaN; this.currentText='';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCSV(){
|
// ════════════════════════════════════════════════════════
|
||||||
if(!records.length) return;
|
// ★ 전역 resize 리스너 (1개만) + 디바운스
|
||||||
let cols=['Time_us','ID','DLC','Len','FD','BRS','EXT'];
|
// ════════════════════════════════════════════════════════
|
||||||
signals.forEach(s=>cols.push(s.name));
|
let resizeTimer=null;
|
||||||
let rows=[cols.join(',')];
|
window.addEventListener('resize', () => {
|
||||||
records.forEach(r=>{
|
clearTimeout(resizeTimer);
|
||||||
let row=[r.ts,(r.id>>>0).toString(16).toUpperCase(),r.dlc,r.len,
|
resizeTimer = setTimeout(() => {
|
||||||
(r.flags&0x20)?1:0,(r.flags&0x10)?1:0,(r.flags&0x80)?1:0];
|
charts.forEach(c => { c._syncCanvas(); });
|
||||||
signals.forEach(s=>{
|
dirty = true;
|
||||||
if(s.id===r.id) row.push((extractBits(r.data,s.startBit,s.bitLen)*s.scale).toFixed(4));
|
}, 150);
|
||||||
else row.push('');
|
});
|
||||||
});
|
|
||||||
rows.push(row.join(','));
|
// ════════════════════════════════════════════════════════
|
||||||
|
// ★ RAF 렌더 루프 — 1프레임에 모든 차트를 한 번씩 draw
|
||||||
|
// dirty 플래그가 false면 skip (CPU 0%)
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
function renderLoop(ts) {
|
||||||
|
rafId = requestAnimationFrame(renderLoop);
|
||||||
|
|
||||||
|
// FPS 계산 (매 60프레임마다 갱신)
|
||||||
|
fpsCount++;
|
||||||
|
if (ts - lastFpsTime >= 1000) {
|
||||||
|
fps = fpsCount;
|
||||||
|
fpsCount = 0;
|
||||||
|
lastFpsTime = ts;
|
||||||
|
document.getElementById('stat-fps').textContent = fps;
|
||||||
|
// 통계 갱신 (1초마다)
|
||||||
|
updateStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dirty) return; // ★ 새 데이터 없으면 그리지 않음 → CPU 0
|
||||||
|
dirty = false;
|
||||||
|
|
||||||
|
charts.forEach(c => c.draw());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// 신호 디코딩 (통합 단일 함수)
|
||||||
|
// - CAN FD 64바이트 지원
|
||||||
|
// - 실제 메시지 길이만큼만 패딩 (불필요한 64개 push 제거)
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
function decodeSignal(signal, rawData) {
|
||||||
|
// rawData: Uint8Array | number[] | hex string
|
||||||
|
let bytes;
|
||||||
|
if (typeof rawData === 'string') {
|
||||||
|
const clean = rawData.replace(/\s/g,'').toUpperCase();
|
||||||
|
const len = Math.min(clean.length >> 1, 64);
|
||||||
|
bytes = new Uint8Array(len);
|
||||||
|
for (let i=0; i<len; i++)
|
||||||
|
bytes[i] = parseInt(clean.substring(i*2, i*2+2), 16) || 0;
|
||||||
|
} else {
|
||||||
|
// Array or Uint8Array
|
||||||
|
bytes = rawData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bLen = bytes.length;
|
||||||
|
let raw = 0;
|
||||||
|
|
||||||
|
if (signal.byteOrder === 'intel') {
|
||||||
|
for (let i=0; i<signal.bitLength; i++) {
|
||||||
|
const bp = signal.startBit + i;
|
||||||
|
const by = bp >> 3, bi = bp & 7;
|
||||||
|
if (by < bLen && (bytes[by] >> bi) & 1) raw |= (1 << i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Motorola
|
||||||
|
for (let i=0; i<signal.bitLength; i++) {
|
||||||
|
const bp = signal.startBit - i;
|
||||||
|
const by = bp >> 3, bi = 7 - (bp & 7);
|
||||||
|
if (by >= 0 && by < bLen && (bytes[by] >> bi) & 1)
|
||||||
|
raw |= (1 << (signal.bitLength - 1 - i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.signed && (raw & (1 << (signal.bitLength-1)))) raw -= (1 << signal.bitLength);
|
||||||
|
return raw * signal.factor + signal.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// WebSocket
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
let ws;
|
||||||
|
function initWebSocket() {
|
||||||
|
ws = new WebSocket('ws://' + window.location.hostname + ':81');
|
||||||
|
ws.onopen = () => updateStatus('Connected', false);
|
||||||
|
ws.onclose = () => { updateStatus('Disconnected', true); setTimeout(initWebSocket, 3000); };
|
||||||
|
ws.onmessage = e => {
|
||||||
|
if (!graphing) return;
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(e.data); } catch { return; }
|
||||||
|
if (data.type === 'canBatch')
|
||||||
|
processCANBatch(data.messages);
|
||||||
|
else if ((data.type === 'update' || data.type === 'status') && data.messages?.length)
|
||||||
|
processCANUpdate(data.messages);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(text, isErr) {
|
||||||
|
const el = document.getElementById('status-pill');
|
||||||
|
if (el) { el.textContent = text; el.className = 'status' + (isErr ? ' disconnected' : ''); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── update 타입 처리 ──────────────────────────────────
|
||||||
|
function processCANUpdate(messages) {
|
||||||
|
const elapsed = (Date.now() - startTime) / 1000;
|
||||||
|
const sorted = _sortedSignals();
|
||||||
|
|
||||||
|
for (let mi=0; mi<messages.length; mi++) {
|
||||||
|
const canMsg = messages[mi];
|
||||||
|
let msgId = canMsg.id;
|
||||||
|
if (typeof msgId === 'string') msgId = parseInt(msgId, 16) || 0;
|
||||||
|
if (msgId & 0x80000000) msgId &= 0x1FFFFFFF;
|
||||||
|
|
||||||
|
const prevCnt = lastCanCounts[msgId] || 0;
|
||||||
|
const curCnt = canMsg.count || 0;
|
||||||
|
const diff = curCnt - prevCnt;
|
||||||
|
if (diff <= 0) continue;
|
||||||
|
lastCanCounts[msgId] = curCnt;
|
||||||
|
totalMsgReceived += diff;
|
||||||
|
|
||||||
|
// ★ data → Uint8Array (변환 1회만)
|
||||||
|
let bytes;
|
||||||
|
if (Array.isArray(canMsg.data)) {
|
||||||
|
bytes = new Uint8Array(canMsg.data);
|
||||||
|
} else if (typeof canMsg.data === 'string') {
|
||||||
|
const h = canMsg.data.replace(/\s/g,'').toUpperCase();
|
||||||
|
const l = Math.min(h.length >> 1, 64);
|
||||||
|
bytes = new Uint8Array(l);
|
||||||
|
for (let i=0; i<l; i++) bytes[i] = parseInt(h.substring(i*2,i*2+2),16)||0;
|
||||||
|
} else continue;
|
||||||
|
|
||||||
|
for (let si=0; si<sorted.length; si++) {
|
||||||
|
const sig = sorted[si];
|
||||||
|
if (sig.messageId !== msgId) continue;
|
||||||
|
const chart = charts[si]; // ★ charts[] 직접 인덱싱
|
||||||
|
if (!chart) continue;
|
||||||
|
|
||||||
|
const value = decodeSignal(sig, bytes);
|
||||||
|
const key = msgId + '|' + sig.name;
|
||||||
|
const lastT = lastSignalTimes[key] || 0;
|
||||||
|
const delta = elapsed - lastT;
|
||||||
|
lastSignalTimes[key] = elapsed;
|
||||||
|
|
||||||
|
// count 만큼 데이터 보간 삽입
|
||||||
|
for (let i=0; i<diff; i++) {
|
||||||
|
chart.push(value, lastT + (delta/diff)*i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ DOM 캐시 사용 (getElementById 반복 호출 없음)
|
||||||
|
const el = valueEls[si];
|
||||||
|
if (el) {
|
||||||
|
el.textContent = (sig.valueTable && sig.valueTable[value] !== undefined)
|
||||||
|
? sig.valueTable[value]
|
||||||
|
: value.toFixed(2) + (sig.unit ? ' ' + sig.unit : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirty = true; // ★ draw 요청 (RAF 루프가 처리)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── canBatch 타입 처리 ────────────────────────────────
|
||||||
|
function processCANBatch(messages) {
|
||||||
|
const elapsed = (Date.now() - startTime) / 1000;
|
||||||
|
const sorted = _sortedSignals();
|
||||||
|
totalMsgReceived += messages.length;
|
||||||
|
|
||||||
|
for (let mi=0; mi<messages.length; mi++) {
|
||||||
|
const canMsg = messages[mi];
|
||||||
|
let msgId = parseInt((canMsg.id||'').toString().replace(/0x/i,''), 16) || 0;
|
||||||
|
if (msgId & 0x80000000) msgId &= 0x1FFFFFFF;
|
||||||
|
|
||||||
|
for (let si=0; si<sorted.length; si++) {
|
||||||
|
const sig = sorted[si];
|
||||||
|
if (sig.messageId !== msgId) continue;
|
||||||
|
const chart = charts[si];
|
||||||
|
if (!chart) continue;
|
||||||
|
|
||||||
|
const value = decodeSignal(sig, canMsg.data);
|
||||||
|
chart.push(value, elapsed);
|
||||||
|
|
||||||
|
const el = valueEls[si];
|
||||||
|
if (el) {
|
||||||
|
el.textContent = (sig.valueTable && sig.valueTable[value] !== undefined)
|
||||||
|
? sig.valueTable[value]
|
||||||
|
: value.toFixed(2) + (sig.unit ? ' ' + sig.unit : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// 그래프 생성 / 정렬
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
function _sortedSignals() {
|
||||||
|
if (sortMode === 'name-asc') return [...selectedSignals].sort((a,b)=>a.name.localeCompare(b.name));
|
||||||
|
if (sortMode === 'name-desc') return [...selectedSignals].sort((a,b)=>b.name.localeCompare(a.name));
|
||||||
|
return selectedSignals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGraphs() {
|
||||||
|
const gd = document.getElementById('graphs');
|
||||||
|
gd.innerHTML = '';
|
||||||
|
charts = [];
|
||||||
|
valueEls = [];
|
||||||
|
|
||||||
|
const sorted = _sortedSignals();
|
||||||
|
sorted.forEach((sig, idx) => {
|
||||||
|
const ct = document.createElement('div');
|
||||||
|
ct.className = 'graph-container';
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
cv.id = 'chart-' + idx;
|
||||||
|
const fdTag = sig.isFD ? '<span class="fd-tag">FD</span>' : '';
|
||||||
|
ct.innerHTML =
|
||||||
|
'<div class="graph-header">' +
|
||||||
|
'<div class="graph-title">' + sig.name + fdTag +
|
||||||
|
' <span style="color:#6b7280;font-size:.85em;">(0x' + sig.messageId.toString(16).toUpperCase() + ')</span>' +
|
||||||
|
(sig.unit ? ' <span style="color:#6b7280;font-size:.85em;">[' + sig.unit + ']</span>' : '') +
|
||||||
|
'</div><div class="graph-value" id="val-' + idx + '">-</div></div>';
|
||||||
|
ct.appendChild(cv);
|
||||||
|
gd.appendChild(ct);
|
||||||
|
|
||||||
|
const chart = new SimpleChart(cv, sig, idx);
|
||||||
|
charts.push(chart);
|
||||||
|
valueEls.push(document.getElementById('val-' + idx)); // ★ DOM 캐시
|
||||||
});
|
});
|
||||||
let blob=new Blob([rows.join('\n')],{type:'text/csv'});
|
|
||||||
let a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
document.getElementById('graph-count').textContent = sorted.length;
|
||||||
a.download='canfd_export.csv';a.click();
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSortMode(mode) {
|
||||||
|
sortMode = mode;
|
||||||
|
document.querySelectorAll('[id^="btn-sort-"]').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById('btn-sort-' + mode)?.classList.add('active');
|
||||||
|
|
||||||
|
// 기존 데이터 보존하며 재배열
|
||||||
|
const gd = document.getElementById('graphs');
|
||||||
|
const old = charts.slice();
|
||||||
|
const origSigs = selectedSignals.slice();
|
||||||
|
const sorted = _sortedSignals();
|
||||||
|
gd.innerHTML = '';
|
||||||
|
charts = [];
|
||||||
|
valueEls = [];
|
||||||
|
|
||||||
|
sorted.forEach((sig, ni) => {
|
||||||
|
const oi = origSigs.findIndex(s => s.name===sig.name && s.messageId===sig.messageId);
|
||||||
|
const ct = document.createElement('div');
|
||||||
|
ct.className = 'graph-container';
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
ct.innerHTML =
|
||||||
|
'<div class="graph-header">' +
|
||||||
|
'<div class="graph-title">' + sig.name +
|
||||||
|
(sig.isFD ? '<span class="fd-tag">FD</span>' : '') +
|
||||||
|
' <span style="color:#6b7280;font-size:.85em;">(0x' + sig.messageId.toString(16).toUpperCase() + ')</span>' +
|
||||||
|
(sig.unit ? ' <span style="color:#6b7280;font-size:.85em;">[' + sig.unit + ']</span>' : '') +
|
||||||
|
'</div><div class="graph-value" id="val-' + ni + '">-</div></div>';
|
||||||
|
ct.appendChild(cv);
|
||||||
|
gd.appendChild(ct);
|
||||||
|
const ch = new SimpleChart(cv, sig, ni);
|
||||||
|
if (oi >= 0 && old[oi]) {
|
||||||
|
// 버퍼 복사
|
||||||
|
ch._buf = old[oi]._buf.slice();
|
||||||
|
ch._tbuf = old[oi]._tbuf.slice();
|
||||||
|
ch._head = old[oi]._head;
|
||||||
|
ch._count = old[oi]._count;
|
||||||
|
ch.currentValue = old[oi].currentValue;
|
||||||
|
ch.currentText = old[oi].currentText;
|
||||||
|
}
|
||||||
|
charts.push(ch);
|
||||||
|
valueEls.push(document.getElementById('val-' + ni));
|
||||||
|
});
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// 제어 함수
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
function startGraphing() {
|
||||||
|
if (!graphing) {
|
||||||
|
graphing = true; startTime = Date.now();
|
||||||
|
lastCanCounts = {}; lastSignalTimes = {};
|
||||||
|
document.getElementById('btn-start').classList.add('active');
|
||||||
|
document.getElementById('btn-stop').classList.remove('active');
|
||||||
|
updateStatus('Recording', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stopGraphing() {
|
||||||
|
graphing = false;
|
||||||
|
document.getElementById('btn-start').classList.remove('active');
|
||||||
|
document.getElementById('btn-stop').classList.add('active');
|
||||||
|
updateStatus('Paused', false);
|
||||||
|
}
|
||||||
|
function setScaleMode(mode) {
|
||||||
|
scaleMode = mode;
|
||||||
|
document.getElementById('btn-index-mode').classList.toggle('active', mode==='index');
|
||||||
|
document.getElementById('btn-time-mode').classList.toggle('active', mode==='time');
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
function setRangeMode(mode) {
|
||||||
|
rangeMode = mode;
|
||||||
|
['10s','30s','all'].forEach(m => {
|
||||||
|
const el = document.getElementById('btn-range-'+m);
|
||||||
|
if (el) el.classList.toggle('active', m===mode);
|
||||||
|
});
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
function setPlotMode(mode) {
|
||||||
|
plotMode = mode;
|
||||||
|
document.getElementById('btn-plot-line').classList.toggle('active', mode==='line');
|
||||||
|
document.getElementById('btn-plot-scatter').classList.toggle('active', mode==='scatter');
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatistics() {
|
||||||
|
let total = 0;
|
||||||
|
charts.forEach(c => total += c._count);
|
||||||
|
document.getElementById('stat-pts').textContent = total;
|
||||||
|
document.getElementById('stat-msgs').textContent = totalMsgReceived;
|
||||||
|
document.getElementById('stat-time').textContent =
|
||||||
|
graphing ? ((Date.now()-startTime)/1000).toFixed(1)+'s' : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearData() {
|
||||||
|
if (!confirm('모든 기록 데이터를 삭제합니다?')) return;
|
||||||
|
charts.forEach(c => c.clear());
|
||||||
|
valueEls.forEach(el => { if (el) el.textContent = '-'; });
|
||||||
|
lastCanCounts = {}; lastSignalTimes = {};
|
||||||
|
totalMsgReceived = 0;
|
||||||
|
if (graphing) startTime = Date.now();
|
||||||
|
dirty = true;
|
||||||
|
updateStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCSV() {
|
||||||
|
if (!charts.length) { alert('No data!'); return; }
|
||||||
|
const sorted = _sortedSignals();
|
||||||
|
let csv = 'Time(s)';
|
||||||
|
sorted.forEach(s => {
|
||||||
|
const u = s.unit ? ' ['+s.unit+']' : '';
|
||||||
|
csv += ',' + s.name + u;
|
||||||
|
if (s.valueTable) csv += ',' + s.name + '_Text';
|
||||||
|
});
|
||||||
|
csv += '\n';
|
||||||
|
let maxLen = 0;
|
||||||
|
charts.forEach(c => { if (c._count > maxLen) maxLen = c._count; });
|
||||||
|
// 원형 버퍼에서 순서대로 읽기
|
||||||
|
for (let i=0; i<maxLen; i++) {
|
||||||
|
let row='', tv='';
|
||||||
|
sorted.forEach((s, idx) => {
|
||||||
|
const ch = charts[idx];
|
||||||
|
if (!ch || i >= ch._count) { row+=','; if (s.valueTable) row+=','; return; }
|
||||||
|
const k = ((ch._head - ch._count + i) % MAX_PTS + MAX_PTS) % MAX_PTS;
|
||||||
|
const v = ch._buf[k];
|
||||||
|
const t = ch._tbuf[k];
|
||||||
|
if (tv==='') tv = t.toFixed(3);
|
||||||
|
row += ',' + v.toFixed(6);
|
||||||
|
if (s.valueTable) {
|
||||||
|
const txt = s.valueTable[v] !== undefined ? s.valueTable[v] : '';
|
||||||
|
row += ',"' + txt + '"';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (tv !== '') csv += tv + row + '\n';
|
||||||
|
}
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g,'-').slice(0,-5);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(new Blob([csv], {type:'text/csv;charset=utf-8;'}));
|
||||||
|
a.download = 'canfd_' + ts + '.csv';
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// localStorage 데이터 로드
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
function loadData() {
|
||||||
|
try {
|
||||||
|
const sigs = localStorage.getItem('selected_signals');
|
||||||
|
const sm = localStorage.getItem('sort_mode');
|
||||||
|
if (!sigs) {
|
||||||
|
alert('신호가 선택되지 않았습니다.\n/graph 페이지에서 DBC 로드 후 신호를 선택하세요.');
|
||||||
|
window.close(); return false;
|
||||||
|
}
|
||||||
|
if (sm) sortMode = sm;
|
||||||
|
selectedSignals = JSON.parse(sigs);
|
||||||
|
return true;
|
||||||
|
} catch(e) {
|
||||||
|
alert('신호 데이터 로드 실패: ' + e.message);
|
||||||
|
window.close(); return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// 진입점
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
if (loadData()) {
|
||||||
|
createGraphs();
|
||||||
|
initWebSocket();
|
||||||
|
startGraphing();
|
||||||
|
requestAnimationFrame(renderLoop); // ★ RAF 루프 시작
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
)rawliteral";
|
)rawliteral";
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
15
index.h
15
index.h
@@ -7,9 +7,13 @@ const char index_html[] PROGMEM = R"rawliteral(
|
|||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
|
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{background:#161b22;border-bottom:1px solid #30363d;display:flex;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
|
||||||
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
|
nav::-webkit-scrollbar{display:none}
|
||||||
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
|
nav .nav-title{color:#43cea2;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:#8b949e;font-size:.78em;font-weight:500;border-bottom:2px solid transparent;white-space:nowrap;transition:color .2s,border-color .2s}
|
||||||
|
nav a:hover{color:#e6edf3}
|
||||||
|
nav a.active{color:#43cea2;border-bottom-color:#43cea2}
|
||||||
|
nav .nav-right{margin-left:auto;display:flex;align-items:center;padding:0 14px}
|
||||||
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
|
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
|
||||||
.container{padding:12px;display:grid;gap:10px}
|
.container{padding:12px;display:grid;gap:10px}
|
||||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||||
@@ -51,12 +55,13 @@ input[type=text]{background:#21262d;border:1px solid #30363d;color:#c9d1d9;paddi
|
|||||||
</style>
|
</style>
|
||||||
</head><body>
|
</head><body>
|
||||||
<nav>
|
<nav>
|
||||||
<span class="title">⚡ CANFD Logger</span>
|
<span class="nav-title">⚡ CANFD Logger</span>
|
||||||
<a href="/" class="active">대시보드</a>
|
<a href="/" class="active">대시보드</a>
|
||||||
<a href="/transmit">송신</a>
|
<a href="/transmit">송신</a>
|
||||||
<a href="/graph">그래프</a>
|
<a href="/graph">그래프</a>
|
||||||
|
<a href="/graph-view">그래프 뷰어</a>
|
||||||
<a href="/settings">설정</a>
|
<a href="/settings">설정</a>
|
||||||
<span style="margin-left:auto;display:flex;align-items:center">
|
<span class="nav-right">
|
||||||
<span class="ws-status ws-err" id="wsLed"></span>
|
<span class="ws-status ws-err" id="wsLed"></span>
|
||||||
<span id="wsLabel" style="font-size:11px;color:#8b949e">연결 중...</span>
|
<span id="wsLabel" style="font-size:11px;color:#8b949e">연결 중...</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
14
settings.h
14
settings.h
@@ -1,6 +1,5 @@
|
|||||||
#ifndef SETTINGS_H
|
#ifndef SETTINGS_H
|
||||||
#define SETTINGS_H
|
#define SETTINGS_H
|
||||||
|
|
||||||
const char settings_html[] PROGMEM = R"rawliteral(
|
const char settings_html[] PROGMEM = R"rawliteral(
|
||||||
<!DOCTYPE html><html lang="ko"><head>
|
<!DOCTYPE html><html lang="ko"><head>
|
||||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -11,6 +10,12 @@ body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-siz
|
|||||||
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
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{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}
|
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
|
||||||
|
nav{background:#161b22;border-bottom:1px solid #30363d;display:flex;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
|
||||||
|
nav::-webkit-scrollbar{display:none}
|
||||||
|
nav .nav-title{color:#43cea2;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:#8b949e;font-size:.78em;font-weight:500;border-bottom:2px solid transparent;white-space:nowrap;transition:color .2s,border-color .2s}
|
||||||
|
nav a:hover{color:#e6edf3}
|
||||||
|
nav a.active{color:#43cea2;border-bottom-color:#43cea2}
|
||||||
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
|
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
|
||||||
.container{padding:16px;max-width:700px}
|
.container{padding:16px;max-width:700px}
|
||||||
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:12px}
|
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:12px}
|
||||||
@@ -38,8 +43,11 @@ input[type=checkbox]{width:16px;height:16px;margin-right:6px;accent-color:#1f6fe
|
|||||||
</style>
|
</style>
|
||||||
</head><body>
|
</head><body>
|
||||||
<nav>
|
<nav>
|
||||||
<span class="title">⚡ CANFD Logger</span>
|
<span class="nav-title">⚡ CANFD Logger</span>
|
||||||
<a href="/">대시보드</a><a href="/transmit">송신</a><a href="/graph">그래프</a>
|
<a href="/">대시보드</a>
|
||||||
|
<a href="/transmit">송신</a>
|
||||||
|
<a href="/graph">그래프</a>
|
||||||
|
<a href="/graph-view">그래프 뷰어</a>
|
||||||
<a href="/settings" class="active">설정</a>
|
<a href="/settings" class="active">설정</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
17
transmit.h
17
transmit.h
@@ -1,6 +1,5 @@
|
|||||||
#ifndef TRANSMIT_H
|
#ifndef TRANSMIT_H
|
||||||
#define TRANSMIT_H
|
#define TRANSMIT_H
|
||||||
|
|
||||||
const char transmit_html[] PROGMEM = R"rawliteral(
|
const char transmit_html[] PROGMEM = R"rawliteral(
|
||||||
<!DOCTYPE html><html lang="ko"><head>
|
<!DOCTYPE html><html lang="ko"><head>
|
||||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -11,6 +10,12 @@ body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-siz
|
|||||||
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
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{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}
|
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
|
||||||
|
nav{background:#161b22;border-bottom:1px solid #30363d;display:flex;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
|
||||||
|
nav::-webkit-scrollbar{display:none}
|
||||||
|
nav .nav-title{color:#43cea2;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:#8b949e;font-size:.78em;font-weight:500;border-bottom:2px solid transparent;white-space:nowrap;transition:color .2s,border-color .2s}
|
||||||
|
nav a:hover{color:#e6edf3}
|
||||||
|
nav a.active{color:#43cea2;border-bottom-color:#43cea2}
|
||||||
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
|
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
|
||||||
.container{padding:12px;display:grid;gap:10px;max-width:900px}
|
.container{padding:12px;display:grid;gap:10px;max-width:900px}
|
||||||
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px}
|
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px}
|
||||||
@@ -38,9 +43,12 @@ td{padding:5px 8px;border-bottom:1px solid #21262d}
|
|||||||
</style>
|
</style>
|
||||||
</head><body>
|
</head><body>
|
||||||
<nav>
|
<nav>
|
||||||
<span class="title">⚡ CANFD Logger</span>
|
<span class="nav-title">⚡ CANFD Logger</span>
|
||||||
<a href="/">대시보드</a><a href="/transmit" class="active">송신</a>
|
<a href="/">대시보드</a>
|
||||||
<a href="/graph">그래프</a><a href="/settings">설정</a>
|
<a href="/transmit" class="active">송신</a>
|
||||||
|
<a href="/graph">그래프</a>
|
||||||
|
<a href="/graph-view">그래프 뷰어</a>
|
||||||
|
<a href="/settings">설정</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
@@ -256,5 +264,4 @@ connect();
|
|||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
)rawliteral";
|
)rawliteral";
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user