<?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>연결 상태 & 마켓 선택</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'; ?>