GNU/_PAGE/monitoring/upbit/market/upbit.php
<?php
require_once '/home/www/GNU/_PAGE/head.php';
// ticker + orderbook + observer(daemon) 상태 FULL FIELD 브라우저 테스트
?>


<title>업비트 통합 모니터링</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500&display=swap">
<style>
:root {
  --bg-900: #020617;
  --bg-800: #0c1224;
  --bg-700: #1a2436;
  --accent-blue:    #3b82f6;
  --accent-cyan:    #06b6d4;
  --accent-emerald: #10b981;
  --accent-violet:  #8b5cf6;
  --accent-amber:   #f59e0b;
  --accent-red:     #ef4444;
  --text-primary:   #e2e8f0;
  --text-secondary: #94a3b8;
  --text-muted:     #475569;
  --border: rgba(48,57,100,0.6);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
  background:var(--bg-900); color:var(--text-primary);
  font-family:'Noto Sans KR',sans-serif; min-height:100vh; min-width:fit-content;
}
body::-webkit-scrollbar       { width:8px; }
body::-webkit-scrollbar-thumb { background:#1a2436; border:1px solid var(--border); border-radius:4px; }
body::-webkit-scrollbar-track { background:var(--bg-900); }

.page-wrapper { padding:24px; }

/* 헤더 */
.mp-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:28px; padding-bottom:20px; border-bottom:1px solid var(--border); }
.mp-header-left  { display:flex; align-items:center; gap:14px; }
.mp-header-right { display:flex; align-items:center; gap:10px; }
.mp-logo { width:40px; height:40px; border-radius:10px; background:linear-gradient(135deg, var(--accent-blue), var(--accent-violet)); display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.mp-logo svg { width:22px; height:22px; fill:white; }
.mp-title    { font-size:20px; font-weight:700; color:var(--text-primary); letter-spacing:-0.3px; }
.mp-subtitle { font-size:12px; color:var(--text-secondary); font-family:'JetBrains Mono',monospace; margin-top:2px; }
.live-badge  { display:flex; align-items:center; gap:6px; background:rgba(16,185,129,0.12); border:1px solid rgba(16,185,129,0.3); border-radius:20px; padding:5px 12px; font-size:12px; color:var(--accent-emerald); font-weight:500; }
.live-dot    { width:7px; height:7px; background:var(--accent-emerald); border-radius:50%; animation:pulse 2s infinite; }
.mp-clock    { font-family:'JetBrains Mono',monospace; font-size:13px; color:var(--text-secondary); background:var(--bg-700); padding:6px 12px; border-radius:8px; border:1px solid var(--border); }
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.8)} }

/* 섹션 라벨 */
.section-label { display:flex; align-items:center; gap:8px; margin-bottom:14px; margin-top:24px; }
.section-dot   { width:4px; height:16px; border-radius:2px; }
.section-label span { font-size:11px; font-weight:500; letter-spacing:1.2px; text-transform:uppercase; color:var(--text-muted); }

/* 컨트롤 바 */
.control-bar { background:var(--bg-800); border:1px solid var(--border); border-radius:14px; padding:18px 22px; display:flex; align-items:center; justify-content:space-between; position:relative; overflow:hidden; }
.control-bar::before { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg, var(--accent-blue), var(--accent-violet)); }
.control-bar-left  { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.control-bar-right { display:flex; align-items:center; gap:14px; }
.badge { display:inline-flex; align-items:center; gap:5px; padding:4px 12px; background:var(--bg-700); border:1px solid var(--border); border-radius:8px; font-size:12px; color:var(--text-secondary); font-family:'JetBrains Mono',monospace; }
.ok   { color:var(--accent-emerald); border-color:rgba(16,185,129,0.3);  background:rgba(16,185,129,0.06); }
.warn { color:var(--accent-amber);   border-color:rgba(245,158,11,0.3);  background:rgba(245,158,11,0.06); }
.err  { color:var(--accent-red);     border-color:rgba(239,68,68,0.3);   background:rgba(239,68,68,0.06); }
select { background:var(--bg-700); color:var(--text-primary); border:1px solid var(--border); border-radius:8px; padding:6px 12px; font-size:13px; outline:none; font-family:'Noto Sans KR',sans-serif; cursor:pointer; }
select:focus { border-color:var(--accent-blue); }
.sound-label { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--text-secondary); cursor:pointer; font-family:'JetBrains Mono',monospace; }
.status-info { font-size:12px; margin-left:4px; font-family:'JetBrains Mono',monospace; }
.idle-count      { color:var(--accent-amber); }
.reconnect-count { color:var(--accent-amber); }
.error-info      { color:var(--accent-red); }

/* 요약 카드 */
.summary-card { background:var(--bg-800); border:1px solid var(--border); border-radius:14px; padding:22px 26px; display:flex; gap:0; position:relative; overflow:hidden; }
.summary-card::before { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg, var(--accent-blue), var(--accent-cyan), var(--accent-violet)); }
.summary-item { flex:1; padding:0 24px; border-right:1px solid var(--border); }
.summary-item:first-child { padding-left:0; }
.summary-item:last-child  { border-right:none; }
.summary-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:1px; margin-bottom:10px; font-family:'JetBrains Mono',monospace; }
.summary-value { font-size:26px; font-weight:700; font-variant-numeric:tabular-nums; letter-spacing:-1px; color:var(--text-primary); }
.summary-item:nth-child(1) .summary-label { color:var(--accent-blue); }
.summary-item:nth-child(1) .summary-value { color:var(--accent-blue); }
.summary-item:nth-child(2) .summary-label { color:var(--accent-emerald); }
.summary-item:nth-child(3) .summary-label { color:var(--accent-amber); }
.summary-item:nth-child(4) .summary-label { color:var(--accent-violet); }

/* 차트 */
.chart-row  { display:flex; gap:14px; }
.chart-wrap { flex:1; background:var(--bg-800); border:1px solid var(--border); border-radius:12px; padding:14px 16px; position:relative; overflow:hidden; }
.chart-wrap:nth-child(1) { border-top:2px solid var(--accent-blue); }
.chart-wrap:nth-child(2) { border-top:2px solid var(--accent-amber); }
.chart-wrap:nth-child(3) { border-top:2px solid var(--accent-violet); }
.chart-label { font-size:10px; color:var(--text-muted); font-weight:600; letter-spacing:0.8px; text-transform:uppercase; font-family:'JetBrains Mono',monospace; margin-bottom:8px; }
canvas { width:100%; height:56px; display:block; border-radius:4px; background:var(--bg-900); }

/* 테이블 */
.tables-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
.table-wrap { background:var(--bg-800); border:1px solid var(--border); border-radius:12px; overflow:hidden; }
.table-wrap:nth-child(1) { border-top:2px solid var(--accent-blue); }
.table-wrap:nth-child(2) { border-top:2px solid var(--accent-cyan); }
.table-wrap:nth-child(3) { border-top:2px solid var(--accent-violet); }
table { width:100%; border-collapse:collapse; font-size:13px; table-layout:fixed; }
.section-header th { background:var(--bg-700) !important; color:var(--text-primary) !important; font-size:12px; padding:12px 15px; text-align:center; font-weight:600; letter-spacing:1px; text-transform:uppercase; border-bottom:1px solid var(--border); font-family:'JetBrains Mono',monospace; }
th, td { border:none; border-bottom:1px solid rgba(48,57,100,0.35); padding:10px 14px; transition:background 0.6s, box-shadow 0.6s, color 0.3s; height:40px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
th { background:var(--bg-800); color:var(--text-muted); width:220px; text-align:left; font-weight:500; font-size:11px; text-transform:uppercase; font-family:'JetBrains Mono',monospace; position:sticky; left:0; z-index:1; }
td { background:var(--bg-800); font-variant-numeric:tabular-nums; color:var(--text-secondary); }
tr:last-child th, tr:last-child td { border-bottom:none; }
tr:hover td { background:var(--bg-700); color:var(--text-primary); }

/* 임팩트 글로우 */
.glow-up       { box-shadow:0 0 20px rgba(52,211,153,0.5);  background:rgba(52,211,153,0.1) !important;  color:#34d399 !important; }
.glow-down     { box-shadow:0 0 20px rgba(248,113,113,0.5); background:rgba(248,113,113,0.1) !important; color:#f87171 !important; }
.glow-big-up   { box-shadow:0 0 35px rgba(52,211,153,0.8);  background:rgba(52,211,153,0.2) !important;  color:#34d399 !important; font-weight:bold; }
.glow-big-down { box-shadow:0 0 35px rgba(248,113,113,0.8); background:rgba(248,113,113,0.2) !important; color:#f87171 !important; font-weight:bold; }
.price-base    { color:var(--text-secondary); }
.price-up      { color:#34d399 !important; }
.price-down    { color:#f87171 !important; }
.rate-positive { color:var(--accent-cyan) !important; }
.rate-negative { color:var(--accent-amber) !important; }
.volume-base   { color:var(--text-muted); }
.volume-surge  { color:#facc15 !important; font-weight:bold; text-shadow:0 0 8px rgba(250,204,21,0.5); }
.status-work   { color:var(--accent-emerald) !important; }
.status-idle   { color:var(--accent-amber) !important; }
.status-warn   { color:var(--accent-amber) !important; }
.status-error  { color:var(--accent-red) !important; }

/* 푸터 */
.page-footer { margin-top:32px; padding-top:20px; border-top:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
.footer-info  { font-size:12px; color:var(--text-muted); font-family:'JetBrains Mono',monospace; }
.footer-tags  { display:flex; gap:8px; }
.footer-tag   { font-size:11px; color:var(--text-muted); background:var(--bg-700); border:1px solid var(--border); border-radius:6px; padding:3px 10px; }
</style>

<div class="page-wrapper">

  <!-- 헤더 -->
  <div class="mp-header">
    <div class="mp-header-left">
      <div class="mp-logo">
        <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
          <path d="M3 3h7v7H3zm11 0h7v7h-7zM3 14h7v7H3zm14 3a4 4 0 110-8 4 4 0 010 8z"/>
        </svg>
      </div>
      <div>
        <div class="mp-title">현재가 + 거래량 통합</div>
        <div class="mp-subtitle">UPBIT REAL-TIME INTEGRATED MONITOR</div>
      </div>
    </div>
    <div class="mp-header-right">
      <div class="live-badge"><span class="live-dot"></span>LIVE</div>
      <div class="mp-clock" id="mp-clock">--:--:--</div>
    </div>
  </div>

  <!-- 컨트롤 -->
  <div class="section-label">
    <div class="section-dot" style="background:var(--accent-blue);"></div>
    <span>연결 상태 &amp; 마켓 선택</span>
  </div>
  <div class="control-bar">
    <div class="control-bar-left">
      <span id="status" class="badge warn">대기</span>
      <span id="statusInfo" class="status-info"></span>
      <span class="badge">수신: <b id="cnt" style="color:#fff;margin-left:4px">0</b></span>
      <span class="badge">마지막수신: <b id="last" style="color:#fff;margin-left:4px">-</b></span>
    </div>
    <div class="control-bar-right">
      <select id="market">
        <option value="KRW-BTC">BTC / 비트코인</option>
        <option value="KRW-ETH">ETH / 이더리움</option>
        <option value="KRW-XRP">XRP / 리플</option>
        <option value="KRW-QTUM">퀀텀</option>
        <option value="KRW-TRUMP">트럼프</option>
      </select>
      <label class="sound-label">
        <input type="checkbox" id="soundToggle"> 알림음
      </label>
    </div>
  </div>

  <!-- 요약 카드 -->
  <div class="section-label" style="margin-top:20px;">
    <div class="section-dot" style="background:var(--accent-cyan);"></div>
    <span>요약 지표</span>
  </div>
  <div class="summary-card">
    <div class="summary-item"><div class="summary-label">현재가</div><div class="summary-value" id="summary-price">-</div></div>
    <div class="summary-item"><div class="summary-label">변동률</div><div class="summary-value" id="summary-rate">-</div></div>
    <div class="summary-item"><div class="summary-label">스프레드</div><div class="summary-value" id="summary-spread">-</div></div>
    <div class="summary-item"><div class="summary-label">오더북 압력</div><div class="summary-value" id="summary-pressure">-</div></div>
  </div>

  <!-- 차트 -->
  <div class="section-label" style="margin-top:20px;">
    <div class="section-dot" style="background:var(--accent-violet);"></div>
    <span>스파크라인</span>
  </div>
  <div class="chart-row">
    <div class="chart-wrap"><div class="chart-label">Price</div><canvas id="chart-price" width="400" height="40"></canvas></div>
    <div class="chart-wrap"><div class="chart-label">Spread</div><canvas id="chart-spread" width="400" height="40"></canvas></div>
    <div class="chart-wrap"><div class="chart-label">Pressure</div><canvas id="chart-pressure" width="400" height="40"></canvas></div>
  </div>

  <!-- 테이블 -->
  <div class="section-label" style="margin-top:20px;">
    <div class="section-dot" style="background:var(--accent-emerald);"></div>
    <span>실시간 데이터</span>
  </div>
  <div class="tables-grid">
    <div class="table-wrap"><table id="tblTicker"></table></div>
    <div class="table-wrap"><table id="tblOrderbook"></table></div>
    <div class="table-wrap"><table id="tblObserver"></table></div>
  </div>

  <!-- 푸터 -->
  <div class="page-footer">
    <div class="footer-info" id="mp-footer-date">UPBIT INTEGRATED MONITOR · v1.0</div>
    <div class="footer-tags">
      <div class="footer-tag">업비트 API</div>
      <div class="footer-tag">실시간</div>
      <div class="footer-tag">WebSocket</div>
    </div>
  </div>

</div>

<script>
// ===== CONFIG: 옵션 설정 (나중에 DB에서 받아오면 여기 덮어쓰기) =====
const CONFIG = {
  ANIM_MS: 400,                    // 애니메이션 지속 시간(ms) - 변경 가능
  IDLE_THRESHOLD_MS: 5000,          // 데이터 없음 판단 기준(ms) - 변경 가능
  BIG_MOVE_THRESHOLD: 0.05,        // 큰 변화 임계치(5%) - 변경 가능
  VOLUME_SURGE_THRESHOLD: 2.0,     // 거래량 급증 임계치(2배) - 변경 가능
  DECIMALS_MAP: {                   // 필드별 소수점 자리수 - 변경 가능
    'price': 0,
    'trade_price': 0,
    'bid1_price': 0,
    'ask1_price': 0,
    'mid_price': 0,
    'spread_price': 0,
    'open_price_today': 0,
    'high_price_today': 0,
    'low_price_today': 0,
    'prev_close_price': 0,
    'trade_price_24h': 0,
    'change_price': 0,
    'tick_trade_value': 0,
    'rate': 6,
    'change_rate': 6,
    'spread_rate': 6,
    'volume_24h': 4,
    'tick_volume': 8,
    'bid1_volume': 8,
    'ask1_volume': 8,
    'orderbook_pressure': 4,
    'summary-price': 0,
    'summary-rate': 4,
    'summary-spread': 0,
    'summary-pressure': 4
  },
  FIELD_TYPE_MAP: {  // 필드 타입 분류 - 변경 가능
    'price': 'price',
    'trade_price': 'price',
    'bid1_price': 'price',
    'ask1_price': 'price',
    'mid_price': 'price',
    'spread_price': 'price',
    'open_price_today': 'price',
    'high_price_today': 'price',
    'low_price_today': 'price',
    'prev_close_price': 'price',
    'trade_price_24h': 'price',
    'change_price': 'price',
    'tick_trade_value': 'price',
    'change_rate': 'rate',
    'spread_rate': 'rate',
    'orderbook_pressure': 'rate',
    'volume_24h': 'volume',
    'tick_volume': 'volume',
    'bid1_volume': 'volume',
    'ask1_volume': 'volume',
    'summary-price': 'price',
    'summary-rate': 'rate',
    'summary-spread': 'price',
    'summary-pressure': 'rate'
  },
  SPARKLINE_LEN: 120,               // 스파크라인 데이터 개수 - 변경 가능
  DRAW_THROTTLE_MS: 1000,            // 스파크라인 그리기 스로틀(ms) - 변경 가능
  SOUND_ENABLED: false               // 기본 알림음 OFF - 변경 가능
};

let ws=null;
let count=0;
let market=document.getElementById('market').value;

const elStatus=document.getElementById('status');
const elStatusInfo=document.getElementById('statusInfo');
const elCnt=document.getElementById('cnt');
const elLast=document.getElementById('last');
const elMarket=document.getElementById('market');
const tblTicker=document.getElementById('tblTicker');
const tblOrderbook=document.getElementById('tblOrderbook');
const tblObserver=document.getElementById('tblObserver');
const soundToggle=document.getElementById('soundToggle');

let TICKER={}, ORDER={};

// ===== observer(daemon) 상태 =====
const DAEMON_PID = Math.floor(Math.random()*90000)+10000;
let DAEMON_LAST_INSERTED = 0;
let DAEMON_LAST_BEAT = 0;
let DAEMON_STATUS = 'work';
let DAEMON_MSG = 'observer active';

// 연결 끊김과 데이터 없음 구분을 위한 변수
let lastRecvTime = 0;
let idleCheckInterval = null;
let reconnectTimer = null;
let reconnectCountdown = 0;
let reconnectStartTime = 0;

// 값 변화 감지 시스템
let prevValues = {};
let animTargets = {};
let animStartValues = {};
let animStartTimes = {};
let animRafId = null;
let cellElements = {};  // 셀 DOM 요소 캐시

// 스파크라인 데이터 버퍼
let sparklineData = {
  price: [],
  spread: [],
  pressure: []
};
let lastDrawTime = 0;

// 캔버스 컨텍스트
const ctxPrice = document.getElementById('chart-price').getContext('2d');
const ctxSpread = document.getElementById('chart-spread').getContext('2d');
const ctxPressure = document.getElementById('chart-pressure').getContext('2d');

function now(){
  const d=new Date();
  const p=n=>String(n).padStart(2,'0');
  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
function serverTime(){
  return new Date().toISOString().slice(0,19).replace('T',' ');
}
function setStatus(t,c){ 
  elStatus.textContent=t; 
  elStatus.className='badge '+c; 
}

// easing 함수: easeOutCubic
function easeOutCubic(t){
  return 1 - Math.pow(1 - t, 3);
}

// 숫자 포맷팅 (천단위 콤마, 소수점 처리)
function formatNumber(value, decimals=0, useComma=true){
  if(value === null || value === undefined || isNaN(value)) return '-';
  const num = Number(value);
  if(isNaN(num)) return '-';
  
  let formatted = num.toFixed(decimals);
  if(useComma && decimals === 0){
    formatted = formatted.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  } else if(useComma){
    const parts = formatted.split('.');
    parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    formatted = parts.join('.');
  }
  return formatted;
}

// 값 변화 감지 및 애니메이션 시작 (색상 클래스 적용 강화)
function updateValue(key, newValue, decimals=0, element=null){
  if(element === null) return;
  
  const prev = prevValues[key];
  const numNew = Number(newValue);
  
  if(isNaN(numNew)){
    element.textContent = '-';
    prevValues[key] = null;
    // 색상 클래스 제거
    element.classList.remove('price-base','price-up','price-down','rate-positive','rate-negative','volume-base','volume-surge');
    return;
  }
  
  // 변화가 없으면 업데이트 안 함
  if(prev !== null && prev !== undefined && Math.abs(prev - numNew) < 0.0000001){
    return;
  }
  
  // 애니메이션 시작
  animStartValues[key] = prev !== null && prev !== undefined ? prev : numNew;
  animTargets[key] = numNew;
  animStartTimes[key] = Date.now();
  cellElements[key] = element;
  
  // 필드 타입 결정
  const fieldType = CONFIG.FIELD_TYPE_MAP[key] || 'price';
  
  // 변화 방향 및 크기 계산
  if(prev !== null && prev !== undefined){
    const diff = numNew - prev;
    const diffPercent = prev !== 0 ? Math.abs(diff / prev) : 0;
    const isUp = diff > 0;
    const isBigMove = diffPercent >= CONFIG.BIG_MOVE_THRESHOLD;
    
    // 기존 색상 클래스 제거
    element.classList.remove('price-base','price-up','price-down','rate-positive','rate-negative','volume-base','volume-surge');
    
    // 필드 타입별 색상 적용
    if(fieldType === 'price'){
      // 가격 계열: 기본 연회색, 상승=초록, 하락=빨강
      if(isUp){
        element.classList.add('price-up');
      } else if(diff < 0){
        element.classList.add('price-down');
      } else {
        element.classList.add('price-base');
      }
    } else if(fieldType === 'rate'){
      // 퍼센트/비율 계열: 양수=청록, 음수=주황
      if(numNew > 0){
        element.classList.add('rate-positive');
      } else if(numNew < 0){
        element.classList.add('rate-negative');
      }
    } else if(fieldType === 'volume'){
      // 거래량/수량 계열: 기본=회색, 급증=노란색 (배수 기준)
      const prevNum = (prev !== null && prev !== undefined) ? Number(prev) : 0;
      const ratio = (prevNum > 0) ? (numNew / prevNum) : 0;
      const isSurge = (ratio >= CONFIG.VOLUME_SURGE_THRESHOLD);
      if(isSurge){
        element.classList.add('volume-surge');
      } else {
        element.classList.add('volume-base');
      }
    }
    
    // 하이라이트 적용 (글로우 효과)
    element.classList.remove('glow-up', 'glow-down', 'glow-big-up', 'glow-big-down');
    if(isBigMove){
      element.classList.add(isUp ? 'glow-big-up' : 'glow-big-down');
    } else if(diff !== 0){
      element.classList.add(isUp ? 'glow-up' : 'glow-down');
    }
    
    // 하이라이트 제거 (900ms 후)
    setTimeout(() => {
      element.classList.remove('glow-up', 'glow-down', 'glow-big-up', 'glow-big-down');
    }, 900);
  } else {
    // 첫 렌더링: 기본 색상만 적용
    if(fieldType === 'price'){
      element.classList.add('price-base');
    } else if(fieldType === 'rate'){
      if(numNew > 0) element.classList.add('rate-positive');
      else if(numNew < 0) element.classList.add('rate-negative');
    } else if(fieldType === 'volume'){
      element.classList.add('volume-base');
    }
  }
  
  prevValues[key] = numNew;
  
  // 애니메이션 루프 시작 (이미 실행 중이면 추가 안 함)
  if(animRafId === null){
    animateValues();
  }
}

// 애니메이션 루프 (requestAnimationFrame)
function animateValues(){
  const now = Date.now();
  let hasActive = false;
  
  for(const key in animTargets){
    if(!cellElements[key]) continue;
    
    const start = animStartValues[key];
    const target = animTargets[key];
    const startTime = animStartTimes[key];
    const elapsed = now - startTime;
    const progress = Math.min(elapsed / CONFIG.ANIM_MS, 1);
    
    if(progress < 1){
      const eased = easeOutCubic(progress);
      const current = start + (target - start) * eased;
      
      // 소수점 자리수 결정
      const decimals = CONFIG.DECIMALS_MAP[key] !== undefined ? CONFIG.DECIMALS_MAP[key] : 0;
      cellElements[key].textContent = formatNumber(current, decimals);
      
      hasActive = true;
    } else {
      // 애니메이션 완료
      const decimals = CONFIG.DECIMALS_MAP[key] !== undefined ? CONFIG.DECIMALS_MAP[key] : 0;
      cellElements[key].textContent = formatNumber(target, decimals);
      delete animTargets[key];
      delete animStartValues[key];
      delete animStartTimes[key];
    }
  }
  
  if(hasActive){
    animRafId = requestAnimationFrame(animateValues);
  } else {
    animRafId = null;
  }
}

// 스파크라인 그리기
function drawSparkline(ctx, data, color='#0f0'){
  const width = ctx.canvas.width;
  const height = ctx.canvas.height;
  
  ctx.clearRect(0, 0, width, height);
  
  if(data.length < 2) return;
  
  // 최소/최대값 계산
  let min = Infinity, max = -Infinity;
  for(let v of data){
    if(v !== null && v !== undefined && !isNaN(v)){
      min = Math.min(min, v);
      max = Math.max(max, v);
    }
  }
  
  if(min === Infinity || max === Infinity || min === max) return;
  
  const range = max - min;
  const padding = 2;
  
  ctx.strokeStyle = color;
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  
  const stepX = (width - padding * 2) / (data.length - 1);
  
  for(let i = 0; i < data.length; i++){
    const x = padding + i * stepX;
    const normalized = (data[i] - min) / range;
    const y = height - padding - normalized * (height - padding * 2);
    
    if(i === 0){
      ctx.moveTo(x, y);
    } else {
      ctx.lineTo(x, y);
    }
  }
  
  ctx.stroke();
}

// 스파크라인 업데이트 (스로틀)
function updateSparklines(){
  const now = Date.now();
  if(now - lastDrawTime < CONFIG.DRAW_THROTTLE_MS) return;
  lastDrawTime = now;
  
  if(sparklineData.price.length > 0){ drawSparkline(ctxPrice, sparklineData.price, '#3b82f6'); }
  if(sparklineData.spread.length > 0){ drawSparkline(ctxSpread, sparklineData.spread, '#f59e0b'); }
  if(sparklineData.pressure.length > 0){ drawSparkline(ctxPressure, sparklineData.pressure, '#8b5cf6'); }
}

// 소리 재생 (간단한 beep)
function playSound(type){
  if(!soundToggle.checked) return;
  
  try{
    const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    const oscillator = audioCtx.createOscillator();
    const gainNode = audioCtx.createGain();
    
    oscillator.connect(gainNode);
    gainNode.connect(audioCtx.destination);
    
    if(type === 'error'){
      oscillator.frequency.value = 400;
      gainNode.gain.value = 0.1;
      oscillator.start();
      oscillator.stop(audioCtx.currentTime + 0.2);
    } else if(type === 'reconnect'){
      oscillator.frequency.value = 600;
      gainNode.gain.value = 0.1;
      oscillator.start();
      oscillator.stop(audioCtx.currentTime + 0.1);
    } else if(type === 'bigmove'){
      oscillator.frequency.value = 800;
      gainNode.gain.value = 0.15;
      oscillator.start();
      oscillator.stop(audioCtx.currentTime + 0.15);
    }
  }catch(e){
    // 오디오 컨텍스트 생성 실패 시 무시
  }
}

function row(k,v,key=null){
  const cellId = key ? `cell-${key}` : null;
  return `<tr><th>${k}</th><td ${cellId ? `id="${cellId}"` : ''}>${v}</td></tr>`;
}

function render(){
  if(!TICKER.code) return;

  const bid1 = ORDER.bid_price ?? 0;
  const ask1 = ORDER.ask_price ?? 0;
  const bidv = ORDER.bid_size ?? 0;
  const askv = ORDER.ask_size ?? 0;

  const mid = (bid1 && ask1) ? (bid1+ask1)/2 : 0;
  const spread_p = (ask1 && bid1) ? ask1-bid1 : 0;
  const spread_r = mid>0 ? spread_p/mid : 0;
  const pressure = (bidv+askv)>0 ? (bidv-askv)/(bidv+askv) : 0;

  // 스파크라인 데이터 업데이트
  const price = TICKER.trade_price ?? 0;
  sparklineData.price.push(price);
  if(sparklineData.price.length > CONFIG.SPARKLINE_LEN) sparklineData.price.shift();
  
  sparklineData.spread.push(spread_p);
  if(sparklineData.spread.length > CONFIG.SPARKLINE_LEN) sparklineData.spread.shift();
  
  sparklineData.pressure.push(pressure);
  if(sparklineData.pressure.length > CONFIG.SPARKLINE_LEN) sparklineData.pressure.shift();
  
  updateSparklines();

  // 요약 카드 업데이트
  const elPrice = document.getElementById('summary-price');
  const elRate = document.getElementById('summary-rate');
  const elSpread = document.getElementById('summary-spread');
  const elPressure = document.getElementById('summary-pressure');
  
  updateValue('summary-price', price, 0, elPrice);
  updateValue('summary-rate', (TICKER.change_rate ?? 0) * 100, 4, elRate);
  updateValue('summary-spread', spread_p, 0, elSpread);
  updateValue('summary-pressure', pressure, 4, elPressure);

  // 테이블 렌더링 - 3열 분리
  tblTicker.innerHTML =
    `<tr class="section-header"><th colspan="2">시세 데이터</th></tr>` +
    row('서버 시간', serverTime()) +
    row('종목 코드', TICKER.code) +
    row('현재가', formatNumber(price, 0), 'trade_price') +
    row('24시간 거래량', formatNumber(TICKER.acc_trade_volume_24h ?? 0, 4), 'volume_24h') +
    row('금일 시가', formatNumber(TICKER.opening_price ?? 0, 0), 'open_price_today') +
    row('금일 고가', formatNumber(TICKER.high_price ?? 0, 0), 'high_price_today') +
    row('금일 저가', formatNumber(TICKER.low_price ?? 0, 0), 'low_price_today') +
    row('전일 종가', formatNumber(TICKER.prev_closing_price ?? 0, 0), 'prev_close_price') +
    row('24시간 거래대금', formatNumber(TICKER.acc_trade_price_24h ?? 0, 0), 'trade_price_24h') +
    row('변동액', formatNumber(TICKER.change_price ?? 0, 0), 'change_price') +
    row('변동률(%)', formatNumber((TICKER.change_rate ?? 0) * 100, 6), 'change_rate') +
    row('최근 체결량', formatNumber(TICKER.trade_volume ?? 0, 8), 'tick_volume') +
    row('체결 금액', formatNumber((TICKER.trade_price||0)*(TICKER.trade_volume||0), 0), 'tick_trade_value');

  tblOrderbook.innerHTML =
    `<tr class="section-header"><th colspan="2">오더북 데이터</th></tr>` +
    row('매수 1호가', formatNumber(bid1, 0), 'bid1_price') +
    row('매도 1호가', formatNumber(ask1, 0), 'ask1_price') +
    row('중간 가격', formatNumber(mid, 0), 'mid_price') +
    row('스프레드(값)', formatNumber(spread_p, 0), 'spread_price') +
    row('스프레드(율)', formatNumber(spread_r, 6), 'spread_rate') +
    row('매수 잔량', formatNumber(bidv, 8), 'bid1_volume') +
    row('매도 잔량', formatNumber(askv, 8), 'ask1_volume') +
    row('오더북 압력', formatNumber(pressure, 4), 'orderbook_pressure') +
    row('업비트 타임스탬프', TICKER.timestamp ?? 0);

  tblObserver.innerHTML =
    `<tr class="section-header"><th colspan="2">관찰자 정보</th></tr>` +
    row('관찰자 상태', DAEMON_STATUS, 'daemon_status') +
    row('프로세스 ID', DAEMON_PID) +
    row('하트비트(ms)', DAEMON_LAST_BEAT) +
    row('최종 삽입 시간', DAEMON_LAST_INSERTED) +
    row('상태 메시지', DAEMON_MSG);

  // 셀 요소 캐시 및 값 업데이트
  updateValue('trade_price', price, 0, document.getElementById('cell-trade_price'));
  updateValue('volume_24h', TICKER.acc_trade_volume_24h ?? 0, 4, document.getElementById('cell-volume_24h'));
  updateValue('open_price_today', TICKER.opening_price ?? 0, 0, document.getElementById('cell-open_price_today'));
  updateValue('high_price_today', TICKER.high_price ?? 0, 0, document.getElementById('cell-high_price_today'));
  updateValue('low_price_today', TICKER.low_price ?? 0, 0, document.getElementById('cell-low_price_today'));
  updateValue('prev_close_price', TICKER.prev_closing_price ?? 0, 0, document.getElementById('cell-prev_close_price'));
  updateValue('trade_price_24h', TICKER.acc_trade_price_24h ?? 0, 0, document.getElementById('cell-trade_price_24h'));
  updateValue('change_price', TICKER.change_price ?? 0, 0, document.getElementById('cell-change_price'));
  updateValue('change_rate', (TICKER.change_rate ?? 0) * 100, 6, document.getElementById('cell-change_rate'));
  updateValue('tick_volume', TICKER.trade_volume ?? 0, 8, document.getElementById('cell-tick_volume'));
  updateValue('tick_trade_value', (TICKER.trade_price||0)*(TICKER.trade_volume||0), 0, document.getElementById('cell-tick_trade_value'));
  updateValue('bid1_price', bid1, 0, document.getElementById('cell-bid1_price'));
  updateValue('ask1_price', ask1, 0, document.getElementById('cell-ask1_price'));
  updateValue('mid_price', mid, 0, document.getElementById('cell-mid_price'));
  updateValue('spread_price', spread_p, 0, document.getElementById('cell-spread_price'));
  updateValue('spread_rate', spread_r, 6, document.getElementById('cell-spread_rate'));
  updateValue('bid1_volume', bidv, 8, document.getElementById('cell-bid1_volume'));
  updateValue('ask1_volume', askv, 8, document.getElementById('cell-ask1_volume'));
  updateValue('orderbook_pressure', pressure, 4, document.getElementById('cell-orderbook_pressure'));
  
  // OBSERVER 상태 색상 적용
  const statusCell = document.getElementById('cell-daemon_status');
  if(statusCell){
    statusCell.classList.remove('status-work','status-idle','status-warn','status-error');
    if(DAEMON_STATUS === 'work'){
      statusCell.classList.add('status-work');
    } else if(DAEMON_STATUS === 'data_none' || DAEMON_STATUS === 'idle'){
      statusCell.classList.add('status-idle');
    } else if(DAEMON_STATUS === 'disconnect'){
      statusCell.classList.add('status-warn');
    } else if(DAEMON_STATUS === 'error'){
      statusCell.classList.add('status-error');
    }
  }
}

// idle 판단 함수
function checkIdle(){
  if(!ws || ws.readyState !== WebSocket.OPEN) return;
  
  const nowMs = Date.now();
  const elapsed = nowMs - lastRecvTime;
  
  if(elapsed >= CONFIG.IDLE_THRESHOLD_MS && lastRecvTime > 0){
    DAEMON_STATUS = 'data_none';
    DAEMON_MSG = `idle: no data for ${Math.floor(elapsed/1000)}s`;
    setStatus('데이터없음','warn');
    elStatusInfo.innerHTML = `<span class="idle-count">무수신 ${Math.floor(elapsed/1000)}초</span>`;
    render();
  }
}

// 재연결 카운트다운 업데이트
function updateReconnectCountdown(){
  if(reconnectStartTime > 0){
    const elapsed = (Date.now() - reconnectStartTime) / 1000;
    const remaining = Math.max(0, 1.5 - elapsed);
    if(remaining > 0){
      elStatusInfo.innerHTML = `<span class="reconnect-count">재연결 ${remaining.toFixed(1)}s</span>`;
      setTimeout(updateReconnectCountdown, 100);
    } else {
      elStatusInfo.innerHTML = '';
      reconnectStartTime = 0;
    }
  }
}

// idle 체크 타이머 시작
function startIdleCheck(){
  if(idleCheckInterval) clearInterval(idleCheckInterval);
  idleCheckInterval = setInterval(checkIdle, 1000);
}

// idle 체크 타이머 중지
function stopIdleCheck(){
  if(idleCheckInterval){
    clearInterval(idleCheckInterval);
    idleCheckInterval = null;
  }
}

// 재연결 타이머 정리
function clearReconnectTimer(){
  if(reconnectTimer){
    clearTimeout(reconnectTimer);
    reconnectTimer = null;
  }
  reconnectStartTime = 0;
  elStatusInfo.innerHTML = '';
}

function connect(){
  if(ws){ 
    try{
      ws.close();
    }catch(e){}
  }
  
  stopIdleCheck();
  clearReconnectTimer();
  
  setStatus('연결중','warn');
  DAEMON_STATUS = 'disconnect';
  DAEMON_MSG = 'connecting...';
  elStatusInfo.innerHTML = '';

  ws=new WebSocket('wss://api.upbit.com/websocket/v1');

  ws.onopen=()=>{
    setStatus('연결됨','ok');
    DAEMON_STATUS = 'work';
    DAEMON_MSG = 'websocket connected';
    lastRecvTime = Date.now();
    elStatusInfo.innerHTML = '';
    startIdleCheck();
    
    ws.send(JSON.stringify([
      {ticket:'cron_0s_full'},
      {type:'ticker',codes:[market]},
      {type:'orderbook',codes:[market]}
    ]));
  };

  ws.onmessage=(e)=>{
    e.data.arrayBuffer().then(buf=>{
      try{
        const d=JSON.parse(new TextDecoder('utf-8').decode(buf));
        if(d.code!==market) return;

        count++;
        elCnt.textContent=count;
        elLast.textContent=now();

        const nowMs = Date.now();
        lastRecvTime = nowMs;
        DAEMON_LAST_BEAT = nowMs;
        DAEMON_LAST_INSERTED = d.timestamp ?? 0;
        DAEMON_STATUS = 'work';
        DAEMON_MSG = 'observer heartbeat ok';
        setStatus('수신중','ok');
        elStatusInfo.innerHTML = '';

        if(d.type==='ticker'){
          TICKER=d;
        }else if(d.type==='orderbook'){
          const u=d.orderbook_units?.[0];
          if(u){
            ORDER={
              bid_price:u.bid_price,
              ask_price:u.ask_price,
              bid_size:u.bid_size,
              ask_size:u.ask_size
            };
          }
        }
        render();
      }catch(err){
        // 파싱 에러 무시
      }
    });
  };

  ws.onclose=()=>{
    stopIdleCheck();
    DAEMON_STATUS='disconnect';
    DAEMON_MSG='websocket closed';
    setStatus('끊김(재연결)','warn');
    
    reconnectStartTime = Date.now();
    updateReconnectCountdown();
    playSound('reconnect');
    
    clearReconnectTimer();
    reconnectTimer = setTimeout(() => {
      connect();
    }, 1500);
  };

  ws.onerror=(e)=>{
    stopIdleCheck();
    DAEMON_STATUS='error';
    DAEMON_MSG='websocket error';
    setStatus('에러','err');
    elStatusInfo.innerHTML = `<span class="error-info">readyState: ${ws.readyState}</span>`;
    playSound('error');
  };
}

elMarket.onchange=()=>{
  market=elMarket.value;
  count=0;
  TICKER={}; ORDER={};
  DAEMON_MSG='market changed';
  lastRecvTime = 0;
  prevValues = {};
  sparklineData = {price:[], spread:[], pressure:[]};
  elCnt.textContent='0';
  elLast.textContent='-';
  connect();
};

connect();

function updateClock(){
  const d = new Date();
  const p = n => String(n).padStart(2,'0');
  const t = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
  const dt = d.toLocaleDateString('ko-KR',{year:'numeric',month:'2-digit',day:'2-digit'});
  const cl = document.getElementById('mp-clock');
  const ft = document.getElementById('mp-footer-date');
  if(cl) cl.textContent = t;
  if(ft) ft.textContent = 'UPBIT INTEGRATED MONITOR · ' + dt;
}
updateClock();
setInterval(updateClock, 1000);
</script>

<?php require_once '/home/www/GNU/_PAGE/tail.php'; ?>