#ifndef GRAPH_VIEWER_H
#define GRAPH_VIEWER_H
const char graph_viewer_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>CAN FD Signal Graph</title>
<style>
: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;}
*{margin:0;padding:0;box-sizing:border-box;}html,body{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:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.header h1{font-size:1.05em;font-weight:700;color:var(--accent);white-space:nowrap;}
.header p{font-size:.8em;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);}
.controls{background:var(--panel);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
.control-group{display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
.control-label{font-size:.72em;color:var(--muted);white-space:nowrap;padding-right:2px;}
.ctrl-sep{width:1px;height:20px;background:var(--border);margin:0 4px;}
.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>
</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>
  <span class="nav-title">⚡ CANFD Logger</span>
  <a href="/">대시보드</a>
  <a href="/transmit">송신</a>
  <a href="/graph">그래프</a>
  <a href="/graph-view" class="active">그래프 뷰어</a>
  <a href="/settings">설정</a>
</nav>
<div class="controls">
  <div class="control-group">
    <button class="btn btn-success active" id="btn-start" onclick="startGraphing()">▶ Start</button>
    <button class="btn btn-danger" id="btn-stop" onclick="stopGraphing()">■ Stop</button>
  </div>
  <div class="ctrl-sep"></div>
  <div class="control-group">
    <span class="control-label">Scale:</span>
    <button class="btn btn-info active" id="btn-index-mode" onclick="setScaleMode('index')">Index</button>
    <button class="btn btn-info" id="btn-time-mode" onclick="setScaleMode('time')">Time</button>
  </div>
  <div class="ctrl-sep"></div>
  <div class="control-group">
    <span class="control-label">Range:</span>
    <button class="btn btn-warning active" id="btn-range-10s" onclick="setRangeMode('10s')">10s</button>
    <button class="btn btn-warning" id="btn-range-30s" onclick="setRangeMode('30s')">30s</button>
    <button class="btn btn-warning" id="btn-range-all" onclick="setRangeMode('all')">All</button>
  </div>
  <div class="ctrl-sep"></div>
  <div class="control-group">
    <span class="control-label">Plot:</span>
    <button class="btn btn-info active" id="btn-plot-line" onclick="setPlotMode('line')">Line</button>
    <button class="btn btn-info" id="btn-plot-scatter" onclick="setPlotMode('scatter')">Dot</button>
  </div>
  <div class="ctrl-sep"></div>
  <div class="control-group">
    <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 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>
// ════════════════════════════════════════════════════════
//  상수 / 전역
// ════════════════════════════════════════════════════════
const DLC_LEN=[0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64];
const MAX_PTS=300;  // 차트당 최대 데이터 포인트

const COLORS=[
  {line:'#FF6384',fill:'rgba(255,99,132,.18)'},{line:'#36A2EB',fill:'rgba(54,162,235,.18)'},
  {line:'#FFCE56',fill:'rgba(255,206,86,.18)'},{line:'#4BC0C0',fill:'rgba(75,192,192,.18)'},
  {line:'#9966FF',fill:'rgba(153,102,255,.18)'},{line:'#FF9F40',fill:'rgba(255,159,64,.18)'},
  {line:'#E74C3C',fill:'rgba(231,76,60,.18)'}, {line:'#3498DB',fill:'rgba(52,152,219,.18)'},
  {line:'#2ECC71',fill:'rgba(46,204,113,.18)'},{line:'#F39C12',fill:'rgba(243,156,18,.18)'},
  {line:'#9B59B6',fill:'rgba(155,89,182,.18)'},{line:'#1ABC9C',fill:'rgba(26,188,156,.18)'},
  {line:'#E67E22',fill:'rgba(230,126,34,.18)'},{line:'#95A5A6',fill:'rgba(149,165,166,.18)'},
  {line:'#58a6ff',fill:'rgba(88,166,255,.18)'},{line:'#43cea2',fill:'rgba(67,206,162,.18)'},
  {line:'#bc8cff',fill:'rgba(188,140,255,.18)'},{line:'#ffa657',fill:'rgba(255,166,87,.18)'},
  {line:'#f85149',fill:'rgba(248,81,73,.18)'}, {line:'#e3b341',fill:'rgba(227,179,65,.18)'}
];

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개로 처리(아래)
  }

  // canvas 물리 픽셀 동기화
  _syncCanvas() {
    const rect = this.canvas.getBoundingClientRect();
    if (!rect.width) return;
    const dpr = window.devicePixelRatio || 1;
    this.canvas.width  = rect.width  * dpr;
    this.canvas.height = rect.height * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.width  = rect.width;
    this.height = rect.height;
  }

  // O(1) 삽입
  push(value, time) {
    const i = this._head % MAX_PTS;
    this._buf[i]  = value;
    this._tbuf[i] = time;
    this._head++;
    if (this._count < MAX_PTS) this._count++;

    this.currentValue = value;
    if (this.signal.valueTable && this.signal.valueTable[value] !== undefined)
      this.currentText = this.signal.valueTable[value];
    else
      this.currentText = '';
  }

  // 표시할 데이터 추출 (슬라이스, 복사 최소화)
  _getDisplay() {
    if (!this._count) return null;

    // all 모드 → 전체
    let n = this._count;
    if (rangeMode !== 'all' && scaleMode === 'index') {
      const N = rangeMode === '30s' ? 150 : 60;
      n = Math.min(n, N);
    }

    // 원형 버퍼에서 순서대로 추출
    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 };
  }

  draw() {
    const ctx = this.ctx, W = this.width, H = this.height;
    if (!W || !H) return;
    const PAD_L=45, PAD_T=8, PAD_B=22, PAD_R=8;
    const gW = W-PAD_L-PAD_R, gH = H-PAD_T-PAD_B;

    ctx.fillStyle = '#0e1117';
    ctx.fillRect(0, 0, W, H);

    if (!this._count) {
      ctx.fillStyle = '#374151';
      ctx.font = '12px monospace';
      ctx.textAlign = 'center';
      ctx.fillText('Waiting for data...', W/2, H/2);
      return;
    }

    const disp = this._getDisplay();
    if (!disp) return;
    const { data: dd, times: dt } = disp;
    const n = dd.length;
    if (!n) return;

    // ★ 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();

    // 좌표 변환
    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='';
  }
}

// ════════════════════════════════════════════════════════
//  ★ 전역 resize 리스너 (1개만) + 디바운스
// ════════════════════════════════════════════════════════
let resizeTimer=null;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(() => {
    charts.forEach(c => { c._syncCanvas(); });
    dirty = true;
  }, 150);
});

// ════════════════════════════════════════════════════════
//  ★ 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 캐시
  });

  document.getElementById('graph-count').textContent = sorted.length;
  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>
</body></html>
)rawliteral";
#endif