GNU/_PAGE/chart/upbit/ecg/volume_mini_simple.php
<?php require_once '/home/www/GNU/_PAGE/head.php'; ?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Volume Heartbeat</title>
<style>
:root {
    --bg-primary: #0a0e27;
    --bg-secondary: #151932;
    --bg-tertiary: #1e2742;
    --bg-card: #1a1f3a;
    --bg-hover: #252b4a;
    --bg-border: #2a3458;
    --text-primary: #e2e8f0;
    --text-secondary: #94a3b8;
    --text-muted: #64748b;
    --accent-primary: #3b82f6;
    --accent-secondary: #8b5cf6;
    --success: #10b981;
    --danger: #ef4444;
    --warning: #f59e0b;
    --border-color: #2a3458;
    --heartbeat-color: #10b981;
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
    --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    background: linear-gradient(135deg, var(--bg-primary) 0%, #0f172a 100%);
    color: var(--text-primary);
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Malgun Gothic', 'Roboto', sans-serif;
    padding: 0px;
    min-height: 100vh;
    line-height: 1.6;
}

h2 {
    font-size: 24px;
    font-weight: 700;
    color: var(--text-primary);
    text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 40px 40px 0 40px;
}

#heartbeat-container {
    padding: 16px;
    background: var(--bg-card);
    border-radius: 7px;
    border: 1px solid var(--border-color);
    box-shadow: var(--shadow-lg);
    transition: all 0.3s ease;
    margin: 15px 40px 0 40px;
}

#heartbeat-container:hover {
    border-color: var(--accent-primary);
    box-shadow: 0 12px 24px rgba(59, 130, 246, 0.15);
}

#heartbeat-title {
    font-size: 14px;
    font-weight: 600;
    margin-bottom: 12px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 12px;
    color: var(--text-primary);
}

#heartbeat-title > span {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 16px;
}

#heartbeat-info {
    font-size: 12px;
    color: var(--text-secondary);
    padding: 6px 12px;
    background: var(--bg-tertiary);
    border-radius: 6px;
    border: 1px solid var(--border-color);
    font-family: 'Courier New', monospace;
    font-weight: 500;
}

#heartbeat-controls {
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
    font-size: 13px;
}

#heartbeat-controls > label {
    color: var(--text-secondary);
    font-weight: 500;
}

#heartbeat-market {
    padding: 8px 12px;
    font-size: 13px;
    background: var(--bg-tertiary);
    color: var(--text-primary);
    border-radius: 8px;
    border: 1px solid var(--border-color);
    cursor: pointer;
    transition: all 0.2s ease;
    font-weight: 500;
    outline: none;
}

#heartbeat-market:hover {
    background: var(--bg-hover);
    border-color: var(--accent-primary);
}

#heartbeat-market:focus {
    border-color: var(--accent-primary);
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

#heartbeat-market option {
    background: var(--bg-card);
    color: var(--text-primary);
    padding: 8px;
}

#heartbeat {
    width: 100%;
    height: 80px;
    background: linear-gradient(to bottom, #000000 0%, #0a0e27 100%);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    display: block;
    margin-top: 12px;
    box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
}

/* 스크롤바 스타일링 */
::-webkit-scrollbar {
    width: 8px;
    height: 8px;
}

::-webkit-scrollbar-track {
    background: var(--bg-secondary);
    border-radius: 4px;
}

::-webkit-scrollbar-thumb {
    background: var(--bg-tertiary);
    border-radius: 4px;
    border: 2px solid var(--bg-secondary);
}

::-webkit-scrollbar-thumb:hover {
    background: var(--bg-hover);
}

/* 반응형 디자인 */
@media (max-width: 768px) {
    body {
        padding: 16px;
    }
    
    h2 {
        font-size: 20px;
    }
    
    #heartbeat-title {
        flex-direction: column;
        align-items: flex-start;
    }
    
    #heartbeat-controls {
        width: 100%;
        flex-direction: column;
        align-items: flex-start;
    }
    
    #heartbeat-market {
        width: 100%;
    }
}

/* 연결 상태 표시용 (추가 가능) */
.connection-status {
    display: inline-block;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--success);
    margin-right: 6px;
    box-shadow: 0 0 6px var(--success);
    animation: pulse 2s infinite;
}

@keyframes pulse {
    0%, 100% {
        opacity: 1;
    }
    50% {
        opacity: 0.5;
    }
}
</style>
</head>
<body>

<h2>📈 거래량 심전도 - 미니 풀 버전</h2>

<div id="heartbeat-container">
    <div id="heartbeat-title">
        <span>거래량 심전도 (Volume Heartbeat) Mini Full</span>
        <div id="heartbeat-controls">
            <label for="heartbeat-market">감시 코인:</label>
            <select id="heartbeat-market">
                <option value="KRW-BTC">BTC</option>
                <option value="KRW-ETH">ETH</option>
                <option value="KRW-XRP">XRP</option>
                <option value="KRW-QTUM">QTUM</option>
                <option value="KRW-TRUMP">TRUMP</option>
            </select>
            <span id="heartbeat-info">데이터 대기 중...</span>
        </div>
    </div>
    <canvas id="heartbeat" width="800" height="80"></canvas>
</div>

<script>
// 상수 설정
const HEARTBEAT_POINTS = 120;
const MINUTE_BUFFER_SIZE = 60; // 60초 버퍼 (1분 집계용)
const CLEAR_BUFFER_ON_RECONNECT = false; // WS 재연결 시 버퍼 초기화 여부
const MAX_FPS = 60;
const SPIKE_THRESHOLD = 3.0; // 평균 대비 3배 이상 증가 시 spike 감지
const NO_TRADE_GRAY_THRESHOLD = 2000; // 2초 무거래
const NO_TRADE_FLAT_THRESHOLD = 10000; // 10초 무거래
const CLIP_PERCENTILE = 0.99; // 상위 1% 클리핑
const FIX_BASELINE_TO_MINUTE = false; // 분 기준 기준선 고정 옵션
const DEBUG_MODE = false; // 내부 상태 디버그 토글
const WS_QUALITY_THRESHOLD = 5; // 초당 최소 메시지 수 (이하 시 경고)
const MICRO_TRADE_RATIO = 0.05; // 분 평균 대비 5% 미만 시 미세 거래 구간

let heartbeatMarket = "KRW-BTC";
let heartbeatData = []; // 초 단위 데이터 (표시용)
let minuteBuffer = []; // 60초 버퍼 (집계용)
let lastTickTime = null;
let tickCount1s = 0;
let tickCount5s = 0;
let tickTimestamps = []; // 틱 시간 기록
let spikeMarkers = []; // spike 마커 위치
let spikeHistory = []; // spike 발생 시간 기록
let renderRequested = false;
let lastRenderTime = 0;
let noTradeStartTime = null;
let isTradingHalted = false;
let fixedBaseline = null; // 고정 기준선 (mean/std)
let lastBaselineUpdate = null; // 마지막 기준선 갱신 시간
let wsMessageCount = 0; // WS 수신 메시지 카운트
let wsQualityTimer = null; // WS 품질 체크 타이머
let lastWsQualityCheck = Date.now();

const heartbeatCanvas = document.getElementById('heartbeat');
const hbCtx = heartbeatCanvas.getContext('2d');
const heartbeatInfoEl = document.getElementById('heartbeat-info');
const heartbeatSelect = document.getElementById('heartbeat-market');

// Observer endpoint (필요시 수정)
const OBSERVER_ENDPOINT = '/observer'; // 실제 엔드포인트로 변경 필요

// 디버그 로그
function debugLog(...args) {
    if (DEBUG_MODE) {
        console.log('[VolumeECG]', ...args);
    }
}

// Canvas 크기 조정 (반응형)
function resizeCanvas() {
    const container = heartbeatCanvas.parentElement;
    const containerWidth = container.clientWidth;
    heartbeatCanvas.width = containerWidth;
    heartbeatCanvas.height = 80;
    requestRender();
}

window.addEventListener('resize', resizeCanvas);
resizeCanvas();

heartbeatSelect.addEventListener('change', () => {
    heartbeatMarket = heartbeatSelect.value;
    heartbeatData = [];
    minuteBuffer = [];
    spikeMarkers = [];
    spikeHistory = [];
    tickTimestamps = [];
    lastTickTime = null;
    noTradeStartTime = null;
    isTradingHalted = false;
    fixedBaseline = null;
    lastBaselineUpdate = null;
    requestRender();
});

// 메모리 정리 (보조 배열 정리)
function cleanupMemory() {
    const now = Date.now();
    const maxAge = 120000; // 2분
    
    // 오래된 spike 마커 제거
    while (spikeMarkers.length > 0 && now - spikeMarkers[0].ts > 60000) {
        spikeMarkers.shift();
    }
    
    // 오래된 spike 기록 제거
    while (spikeHistory.length > 0 && spikeHistory[0] < now - 60000) {
        spikeHistory.shift();
    }
    
    // 오래된 틱 타임스탬프 제거
    while (tickTimestamps.length > 0 && now - tickTimestamps[0] > 5000) {
        tickTimestamps.shift();
    }
    
    debugLog('메모리 정리 완료', {
        spikeMarkers: spikeMarkers.length,
        spikeHistory: spikeHistory.length,
        tickTimestamps: tickTimestamps.length
    });
}

// 틱 속도 계산
function updateTickSpeed(ts) {
    const now = ts || Date.now();
    tickTimestamps.push(now);
    
    // 5초 이상 오래된 틱 제거
    while (tickTimestamps.length > 0 && now - tickTimestamps[0] > 5000) {
        tickTimestamps.shift();
    }
    
    // 1초 틱 수 계산
    const recent1s = tickTimestamps.filter(t => now - t <= 1000).length;
    // 5초 틱 수 계산
    const recent5s = tickTimestamps.length;
    
    tickCount1s = recent1s;
    tickCount5s = recent5s;
}

// 무거래 감지
function checkNoTrade(ts) {
    if (lastTickTime === null) {
        noTradeStartTime = null;
        isTradingHalted = false;
        return;
    }
    
    const elapsed = ts - lastTickTime;
    
    if (elapsed >= NO_TRADE_FLAT_THRESHOLD) {
        if (!isTradingHalted) {
            isTradingHalted = true;
            requestRender();
        }
    } else if (elapsed < NO_TRADE_GRAY_THRESHOLD) {
        if (isTradingHalted) {
            isTradingHalted = false;
            noTradeStartTime = null;
            requestRender();
        }
    }
}

// 거래량 압축 보호 (상위 1% 클리핑)
function clipExtremeValues(values) {
    if (values.length < 10) return values;
    
    const sorted = [...values].sort((a, b) => a - b);
    const clipIndex = Math.floor(sorted.length * CLIP_PERCENTILE);
    const clipValue = sorted[clipIndex];
    
    return values.map(v => Math.min(v, clipValue));
}

// Spike 감지 및 전송
function detectSpike(currentVolume, recentAvg) {
    if (!recentAvg || recentAvg <= 0 || currentVolume <= 0) return false;
    if (isNaN(currentVolume) || isNaN(recentAvg)) return false;
    
    const ratio = currentVolume / recentAvg;
    if (ratio >= SPIKE_THRESHOLD) {
        const now = Date.now();
        spikeHistory.push(now);
        
        // Observer endpoint로 전송
        try {
            fetch(OBSERVER_ENDPOINT, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    type: 'volume_spike',
                    market: heartbeatMarket,
                    volume: currentVolume,
                    avg_volume: recentAvg,
                    ratio: ratio,
                    timestamp: now
                })
            }).catch(err => {
                if (DEBUG_MODE) console.error('Observer 전송 실패:', err);
            });
        } catch (e) {
            if (DEBUG_MODE) console.error('Observer 전송 오류:', e);
        }
        return true;
    }
    return false;
}

// 1분 집계 계산 (mean/std용)
function calculateMinuteStats() {
    if (minuteBuffer.length === 0) return { mean: 0, std: 0 };
    
    let sum = 0;
    let sumSq = 0;
    let validCount = 0;
    
    for (let i = 0; i < minuteBuffer.length; i++) {
        const v = minuteBuffer[i].volume || 0;
        if (isNaN(v) || v < 0) continue;
        sum += v;
        sumSq += v * v;
        validCount++;
    }
    
    if (validCount === 0) return { mean: 0, std: 0 };
    
    const mean = sum / validCount;
    const variance = Math.max(0, sumSq / validCount - mean * mean);
    const std = Math.sqrt(variance);
    
    return { mean, std };
}

// 거래량 방향성 계산 (직전 분 평균 대비)
function calculateVolumeDirection() {
    if (heartbeatData.length < 10 || minuteBuffer.length < 10) return null;
    
    const recent10 = heartbeatData.slice(-10);
    const recentAvg = recent10.reduce((sum, d) => sum + (d.volume || 0), 0) / recent10.length;
    
    const minuteStats = calculateMinuteStats();
    const minuteAvg = minuteStats.mean || 0;
    
    if (minuteAvg <= 0) return null;
    
    const ratio = recentAvg / minuteAvg;
    if (ratio > 1.1) return 'up'; // 10% 이상 증가
    if (ratio < 0.9) return 'down'; // 10% 이상 감소
    return 'neutral';
}

// 미세 거래량 구간 감지
function detectMicroTrade() {
    if (heartbeatData.length < 10 || minuteBuffer.length < 10) return false;
    
    const now = Date.now();
    const recent10s = heartbeatData.filter(d => now - d.ts <= 10000);
    if (recent10s.length === 0) return false;
    
    const recent10sAvg = recent10s.reduce((sum, d) => sum + (d.volume || 0), 0) / recent10s.length;
    const minuteStats = calculateMinuteStats();
    const minuteAvg = minuteStats.mean || 0;
    
    if (minuteAvg <= 0) return false;
    
    return recent10sAvg < minuteAvg * MICRO_TRADE_RATIO;
}

// 거래량 리듬 지수 계산 (5초 vs 30초)
function calculateRhythmIndex() {
    if (heartbeatData.length < 30) return null;
    
    const now = Date.now();
    const recent5s = heartbeatData.filter(d => now - d.ts <= 5000);
    const recent30s = heartbeatData.filter(d => now - d.ts <= 30000);
    
    if (recent5s.length === 0 || recent30s.length === 0) return null;
    
    const vol5s = recent5s.reduce((sum, d) => sum + (d.volume || 0), 0);
    const vol30s = recent30s.reduce((sum, d) => sum + (d.volume || 0), 0);
    
    if (vol30s <= 0) return null;
    
    return vol5s / vol30s;
}

// 스파이크 누적 카운트 (1분)
function getSpikeCount1m() {
    const now = Date.now();
    return spikeHistory.filter(ts => now - ts <= 60000).length;
}

// 시간 정렬 보호: ts 역전 체크
function isValidTimestamp(ts) {
    if (heartbeatData.length === 0) return true;
    const lastTs = heartbeatData[heartbeatData.length - 1].ts;
    return ts >= lastTs;
}

// 데이터 업데이트
function updateHeartbeat(ts, volume) {
    // 데이터 검증
    if (!ts || !volume || volume <= 0 || isNaN(volume)) return;
    
    // 시간 정렬 보호
    if (!isValidTimestamp(ts)) {
        debugLog('시간 역전 데이터 무시:', ts);
        return;
    }
    
    const now = ts || Date.now();
    lastTickTime = now;
    
    // 무거래 상태 해제
    if (isTradingHalted) {
        isTradingHalted = false;
        noTradeStartTime = null;
    }
    
    // 1분 버퍼 업데이트
    minuteBuffer.push({ ts: now, volume: volume });
    while (minuteBuffer.length > MINUTE_BUFFER_SIZE) {
        minuteBuffer.shift();
    }
    
    // 기준선 고정 옵션 처리
    if (FIX_BASELINE_TO_MINUTE) {
        const currentMinute = Math.floor(now / 60000);
        if (fixedBaseline === null || lastBaselineUpdate !== currentMinute) {
            fixedBaseline = calculateMinuteStats();
            lastBaselineUpdate = currentMinute;
            debugLog('기준선 갱신:', fixedBaseline);
        }
    }
    
    // 초 단위 데이터 업데이트 (표시용)
    heartbeatData.push({ ts: now, volume: volume, isGray: false });
    while (heartbeatData.length > HEARTBEAT_POINTS) {
        heartbeatData.shift();
    }
    
    // 최근 2초 데이터를 회색 처리
    for (let i = heartbeatData.length - 1; i >= 0; i--) {
        const elapsed = now - heartbeatData[i].ts;
        heartbeatData[i].isGray = elapsed >= NO_TRADE_GRAY_THRESHOLD && elapsed < NO_TRADE_FLAT_THRESHOLD;
    }
    
    // Spike 감지
    if (heartbeatData.length >= 10) {
        const recent10 = heartbeatData.slice(-10);
        const recentAvg = recent10.reduce((sum, d) => sum + (d.volume || 0), 0) / recent10.length;
        if (detectSpike(volume, recentAvg)) {
            spikeMarkers.push({ index: heartbeatData.length - 1, ts: now });
        }
    }
    
    // 틱 속도 업데이트
    updateTickSpeed(now);
    
    // 메모리 정리 (주기적)
    if (heartbeatData.length % 50 === 0) {
        cleanupMemory();
    }
    
    requestRender();
}

// 렌더 요청 (FPS 제한)
function requestRender() {
    if (!renderRequested) {
        renderRequested = true;
        requestAnimationFrame(() => {
            const now = performance.now();
            if (now - lastRenderTime >= 1000 / MAX_FPS) {
                lastRenderTime = now;
                drawHeartbeat();
            }
            renderRequested = false;
        });
    }
}

// 무거래 체크 (주기적)
setInterval(() => {
    const now = Date.now();
    checkNoTrade(now);
    requestRender();
}, 500);

function drawHeartbeat() {
    const ctx = hbCtx;
    const w = heartbeatCanvas.width;
    const h = heartbeatCanvas.height;
    ctx.clearRect(0, 0, w, h);

    if (heartbeatData.length < 2) {
        ctx.fillStyle = "#64748b";
        ctx.font = "12px Arial";
        ctx.textAlign = "center";
        ctx.fillText("데이터 수집 중...", w / 2, h / 2);
        heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 데이터 수집 중";
        return;
    }

    // 무거래 평선 처리
    if (isTradingHalted) {
        const meanStats = FIX_BASELINE_TO_MINUTE && fixedBaseline ? fixedBaseline : calculateMinuteStats();
        const meanY = h / 2;
        
        ctx.strokeStyle = "#64748b";
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(0, meanY);
        ctx.lineTo(w, meanY);
        ctx.stroke();
        
        ctx.fillStyle = "#ef4444";
        ctx.font = "11px Arial";
        ctx.textAlign = "center";
        ctx.fillText("거래 정지 구간", w / 2, meanY - 10);
        
        const spikeCount = getSpikeCount1m();
        heartbeatInfoEl.textContent = `집계: 1분 / 표현: 초 | 거래 정지 구간 | 틱: ${tickCount1s}/5s | 스파이크: ${spikeCount}/1m`;
        return;
    }

    // 값 추출 및 검증
    const volumes = heartbeatData.map(d => {
        const v = d.volume || 0;
        return isNaN(v) || v < 0 ? 0 : v;
    }).filter(v => v > 0);

    if (volumes.length === 0) {
        heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 유효 데이터 없음";
        return;
    }

    // 압축 보호: 상위 1% 클리핑
    const clippedVolumes = clipExtremeValues(volumes);
    let minV = Math.min(...clippedVolumes);
    let maxV = Math.max(...clippedVolumes);
    
    if (maxV <= 0 || isNaN(maxV)) maxV = 1;
    if (minV < 0 || isNaN(minV)) minV = 0;
    const range = maxV - minV || 1;

    // 1분 집계 통계 (기준선용)
    let minuteStats;
    if (FIX_BASELINE_TO_MINUTE && fixedBaseline) {
        minuteStats = fixedBaseline;
    } else {
        minuteStats = calculateMinuteStats();
    }
    const mean = minuteStats.mean || 0;
    const std = minuteStats.std || 0;

    // 추가 계산
    const direction = calculateVolumeDirection();
    const isMicroTrade = detectMicroTrade();
    const rhythmIndex = calculateRhythmIndex();
    const spikeCount = getSpikeCount1m();
    
    // 정보 표시 문자열 생성
    let infoParts = ['집계: 1분 / 표현: 초', `틱: ${tickCount1s}/5s`];
    
    if (isMicroTrade) {
        infoParts.push('미세 거래 구간');
    }
    
    if (rhythmIndex !== null && !isNaN(rhythmIndex)) {
        infoParts.push(`RI: ${rhythmIndex.toFixed(2)}`);
    }
    
    infoParts.push(`평균≈${mean.toFixed(4)}`, `σ≈${std.toFixed(4)}`);
    infoParts.push(`스파이크: ${spikeCount}/1m`);
    
    heartbeatInfoEl.textContent = infoParts.join(' | ');

    // 그리기
    ctx.lineWidth = 2;
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    ctx.shadowBlur = 4;
    ctx.shadowColor = "rgba(16, 185, 129, 0.5)";

    // 방향성에 따른 명도 조절
    let brightness = 1.0;
    if (direction === 'up') {
        brightness = 1.1; // 10% 밝게
    } else if (direction === 'down') {
        brightness = 0.9; // 10% 어둡게
    }
    
    // 그라데이션 생성 (명도 조절)
    const createGradient = () => {
        const grad = ctx.createLinearGradient(0, 0, 0, h);
        const applyBrightness = (r, g, b) => {
            return `rgb(${Math.round(Math.min(255, r * brightness))}, ${Math.round(Math.min(255, g * brightness))}, ${Math.round(Math.min(255, b * brightness))})`;
        };
        grad.addColorStop(0, applyBrightness(16, 185, 129)); // #10b981
        grad.addColorStop(0.5, applyBrightness(5, 150, 105)); // #059669
        grad.addColorStop(1, applyBrightness(4, 120, 87)); // #047857
        return grad;
    };

    // 메인 라인 (초 단위)
    ctx.beginPath();
    let hasPath = false;
    let currentGradient = null;
    
    for (let i = 0; i < heartbeatData.length; i++) {
        const x = (i / (heartbeatData.length - 1)) * (w - 2) + 1;
        const v = heartbeatData[i].volume || 0;
        if (isNaN(v) || v < 0) continue;
        
        const clippedV = Math.min(v, maxV);
        const yRatio = (clippedV - minV) / range;
        const y = h - 1 - yRatio * (h - 2);
        
        if (!hasPath) {
            ctx.moveTo(x, y);
            hasPath = true;
        } else {
            ctx.lineTo(x, y);
        }
        
        // 회색 처리 (2초 이상 무거래)
        if (heartbeatData[i].isGray) {
            ctx.strokeStyle = "#64748b";
            ctx.stroke();
            ctx.beginPath();
            ctx.moveTo(x, y);
            hasPath = true;
            currentGradient = null;
        } else {
            if (!currentGradient) {
                currentGradient = createGradient();
            }
            ctx.strokeStyle = currentGradient;
        }
    }
    
    if (hasPath) {
        ctx.stroke();
    }

    // Spike 마커 표시
    ctx.fillStyle = "#ef4444";
    ctx.shadowBlur = 6;
    ctx.shadowColor = "rgba(239, 68, 68, 0.8)";
    
    for (let marker of spikeMarkers) {
        if (marker.index < heartbeatData.length) {
            const x = (marker.index / (heartbeatData.length - 1)) * (w - 2) + 1;
            const v = heartbeatData[marker.index].volume || 0;
            if (isNaN(v) || v < 0) continue;
            const clippedV = Math.min(v, maxV);
            const yRatio = (clippedV - minV) / range;
            const y = h - 1 - yRatio * (h - 2);
            
            ctx.beginPath();
            ctx.arc(x, y, 4, 0, Math.PI * 2);
            ctx.fill();
        }
    }

    // 그림자 초기화
    ctx.shadowBlur = 0;
}

// WebSocket 품질 감시
function checkWsQuality() {
    const now = Date.now();
    const elapsed = (now - lastWsQualityCheck) / 1000; // 초 단위
    
    if (elapsed >= 1) {
        const msgPerSec = wsMessageCount / elapsed;
        wsMessageCount = 0;
        lastWsQualityCheck = now;
        
        debugLog('WS 품질:', msgPerSec.toFixed(2), 'msg/s');
        
        if (msgPerSec < WS_QUALITY_THRESHOLD && heartbeatData.length > 0) {
            const currentText = heartbeatInfoEl.textContent;
            if (!currentText.includes('데이터 지연 가능')) {
                heartbeatInfoEl.textContent = currentText + ' | [데이터 지연 가능]';
            }
        }
    }
}

// WebSocket
let ws = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;

// WS 품질 감시 시작
wsQualityTimer = setInterval(checkWsQuality, 1000);
lastWsQualityCheck = Date.now();

function connectWS() {
    try {
        ws = new WebSocket("wss://api.upbit.com/websocket/v1");

        ws.onopen = () => {
            reconnectAttempts = 0;
            wsMessageCount = 0;
            lastWsQualityCheck = Date.now();
            ws.send(JSON.stringify([
                { ticket: "volume_only" },
                { type: "ticker", codes: ["KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-QTUM", "KRW-TRUMP"] }
            ]));
            heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결됨: 데이터 수집 중...";
            debugLog('WS 연결됨');
        };

        ws.onmessage = (event) => {
            wsMessageCount++;
            event.data.arrayBuffer().then(buffer => {
                const decoder = new TextDecoder("utf-8");
                const json = decoder.decode(buffer);
                try {
                    const data = JSON.parse(json);
                    const market = data.cd || data.code || "";
                    const volume = Number(data.tv || data.trade_volume || 0);
                    const ts = Date.now();
                    
                    // 데이터 검증
                    if (!market || !volume || volume <= 0 || isNaN(volume)) {
                        return;
                    }
                    
                    if (market === heartbeatMarket) {
                        updateHeartbeat(ts, volume);
                    }
                } catch (e) {
                    if (DEBUG_MODE) console.error("데이터 파싱 오류:", e);
                }
            }).catch(err => {
                if (DEBUG_MODE) console.error("Buffer 디코딩 오류:", err);
            });
        };

        ws.onerror = (error) => {
            if (DEBUG_MODE) console.error("WebSocket 오류:", error);
            heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결 오류 발생";
        };

        ws.onclose = () => {
            heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결 끊김: 재연결 시도 중...";
            debugLog('WS 연결 끊김');
            
            // 재연결 시 버퍼 초기화 옵션
            if (CLEAR_BUFFER_ON_RECONNECT) {
                heartbeatData = [];
                minuteBuffer = [];
                spikeMarkers = [];
                spikeHistory = [];
                tickTimestamps = [];
                lastTickTime = null;
                fixedBaseline = null;
                lastBaselineUpdate = null;
                debugLog('버퍼 초기화됨');
            }
            
            if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
                reconnectAttempts++;
                setTimeout(() => {
                    connectWS();
                }, 3000 * reconnectAttempts);
            } else {
                heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결 실패: 페이지를 새로고침하세요";
            }
        };
    } catch (e) {
        if (DEBUG_MODE) console.error("WebSocket 연결 오류:", e);
        heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결 실패";
    }
}

connectWS();
</script>

</body>
</html>