GNU/_PAGE/chart/upbit/ecg/Price_mini_simple.php
<?php require_once '/home/www/GNU/_PAGE/head.php'; ?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Price 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;
    --neon-green: #00ff88;
    --neon-cyan: #00eaff;
    --neon-glow: rgba(0, 255, 180, 0.5);
    --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::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 2px;
    background: linear-gradient(90deg, transparent, var(--neon-green), transparent);
    opacity: 0.5;
}

#heartbeat-container:hover {
    border-color: var(--neon-green);
    box-shadow: 0 12px 24px rgba(0, 255, 136, 0.15), 0 0 20px rgba(0, 255, 136, 0.1);
}

#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);
    position: relative;
    z-index: 1;
}

#heartbeat-title > span {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 16px;
    text-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
}

#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;
    transition: all 0.2s ease;
}

#heartbeat-info:hover {
    border-color: var(--neon-cyan);
    box-shadow: 0 0 8px rgba(0, 234, 255, 0.2);
}

#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(--neon-green);
    box-shadow: 0 0 8px rgba(0, 255, 136, 0.2);
}

#heartbeat-market:focus {
    border-color: var(--neon-green);
    box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1), 0 0 12px rgba(0, 255, 136, 0.3);
}

#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), 0 0 20px rgba(0, 255, 136, 0.05);
    position: relative;
    overflow: hidden;
}

#heartbeat::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: radial-gradient(circle at center, rgba(0, 255, 136, 0.03) 0%, transparent 70%);
    pointer-events: none;
}

/* 스크롤바 스타일링 */
::-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;
        transform: scale(1);
    }
    50% {
        opacity: 0.7;
        transform: scale(1.1);
    }
}

/* 네온 효과 텍스트 */
.neon-text {
    text-shadow: 
        0 0 5px var(--neon-green),
        0 0 10px var(--neon-green),
        0 0 15px var(--neon-green),
        0 0 20px var(--neon-green);
}
</style>
</head>
<body>

<h2>💹 가격 심전도 - 미니 풀 버전</h2>

<div id="heartbeat-container">
    <div id="heartbeat-title">
        <span>가격 심전도 (Price 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">WS 연결 준비 중...</span>
        </div>
    </div>
    <canvas id="heartbeat" width="800" height="80"></canvas>
</div>

<script>
const HEARTBEAT_POINTS = 120;
let heartbeatMarket = "KRW-BTC";
let heartbeatData = [];
let spikePoints = [];
let lastTickTime = 0;
let isFreeze = false;
let freezeEndTime = 0;
let wsStatus = "disconnected";
let lastVolatilityLevel = null;
let renderRequested = false;
let lastRenderTime = 0;
const MIN_RENDER_INTERVAL = 16;
let lastObserverSent = {};
let lastStats = {mean: 0, std: 0, price: 0};
let lastNoTradeState = false;

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

function getStatusIcon(status){
    if(status === "connected") return "🟢";
    if(status === "reconnecting") return "🟡";
    if(status === "error") return "🔴";
    return "⚪";
}

/* 캔버스 폭을 컨테이너 width에 맞게 자동 조정 */
function resizeHeartbeatCanvas() {
    const rect = heartbeatCanvas.getBoundingClientRect();
    heartbeatCanvas.width = rect.width;
    heartbeatCanvas.height = 80;
    requestRender();
}
resizeHeartbeatCanvas();
window.addEventListener('resize', resizeHeartbeatCanvas);

heartbeatSelect.addEventListener('change', ()=>{
    heartbeatMarket = heartbeatSelect.value;
    heartbeatData = [];
    spikePoints = [];
    lastTickTime = 0;
    isFreeze = false;
    freezeEndTime = 0;
    heartbeatInfoEl.textContent = `심전도: ${heartbeatMarket} 변경, 데이터 대기 중...`;
    requestRender();
    connectWS();
});

function requestRender(){
    if(!renderRequested){
        renderRequested = true;
        requestAnimationFrame(()=>{
            const now = performance.now();
            if(now - lastRenderTime >= MIN_RENDER_INTERVAL){
                lastRenderTime = now;
                drawHeartbeat();
            }
            renderRequested = false;
        });
    }
}

function updateHeartbeat(ts, price){
    lastTickTime = ts;
    
    if(heartbeatData.length > 0){
        const prevPrice = heartbeatData[heartbeatData.length - 1].price;
        const changePercent = Math.abs((price - prevPrice) / prevPrice);
        
        if(changePercent >= 0.005 && !isFreeze){
            isFreeze = true;
            freezeEndTime = Date.now() + 2000;
            triggerObserver({
                market: heartbeatMarket,
                price: lastStats.price || price,
                mean: lastStats.mean,
                std: lastStats.std,
                type: "freeze",
                ts: ts
            });
        }
        
        if(changePercent >= 0.0015){
            const spikeIndex = heartbeatData.length;
            spikePoints.push({index: spikeIndex, dir: (price > prevPrice ? 'up' : 'down')});
            triggerObserver({
                market: heartbeatMarket,
                price: lastStats.price || price,
                mean: lastStats.mean,
                std: lastStats.std,
                type: "spike",
                ts: ts
            });
        }
    }
    
    heartbeatData.push({ts, price});
    
    while(heartbeatData.length > HEARTBEAT_POINTS){
        heartbeatData.shift();
        if(spikePoints.length > 0 && spikePoints[0].index === 0){
            spikePoints.shift();
        }
        for(let i = 0; i < spikePoints.length; i++){
            spikePoints[i].index--;
        }
    }
    
    if(isFreeze && Date.now() < freezeEndTime){
        return;
    }
    
    requestRender();
}

function triggerObserver(data){
    const now = Date.now();
    const lastSent = lastObserverSent[data.type] || 0;
    if(now - lastSent < 1000){
        return;
    }
    lastObserverSent[data.type] = now;
    
    fetch('/observer/heartbeat_event.php', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(data)
    }).catch(()=>{});
}

/* ----------------------------------------------------
   🔥 네온 심전도 (평균 기준 변동폭으로 스케일링해서 "안보이는 평선" 방지)
---------------------------------------------------- */
function drawHeartbeat(){
    const ctx = hbCtx;
    const w = heartbeatCanvas.width;
    const h = heartbeatCanvas.height;

    ctx.clearRect(0, 0, w, h);

    /* 배경 그리드 */
    ctx.strokeStyle = "rgba(0, 255, 136, 0.08)";
    ctx.lineWidth = 1;

    ctx.beginPath();
    for(let x=0; x<w; x+=40){
        ctx.moveTo(x,0);
        ctx.lineTo(x,h);
    }
    for(let y=0; y<h; y+=20){
        ctx.moveTo(0,y);
        ctx.lineTo(w,y);
    }
    ctx.stroke();

    if(heartbeatData.length < 2){
        ctx.fillStyle = "#64748b";
        ctx.font = "12px Arial";
        ctx.textAlign = "center";
        ctx.fillText("데이터 수집 중...", w/2, h/2);
        heartbeatInfoEl.textContent = getStatusIcon(wsStatus) + " 심전도: 데이터 수집 중";
        return;
    }

    /* 프리즈 상태 확인 */
    const now = Date.now();
    if(isFreeze && now < freezeEndTime){
        ctx.fillStyle = "#ff4444";
        ctx.font = "12px Arial";
        ctx.textAlign = "center";
        ctx.fillText("발작 감지 – 프리즈", w/2, h/2);
        heartbeatInfoEl.textContent = getStatusIcon(wsStatus) + " 발작 감지 – 프리즈";
        return;
    }else if(isFreeze && now >= freezeEndTime){
        isFreeze = false;
        freezeEndTime = 0;
    }

    /* 무거래 감지 */
    const isNoTrade = (!isFreeze) && (now - lastTickTime >= 2000);

    /* 평균, 표준편차 */
    let sum = 0, sumSq = 0;
    for(const d of heartbeatData){
        sum += d.price;
        sumSq += d.price * d.price;
    }
    const n = heartbeatData.length;
    const mean = sum / n;
    const variance = Math.max(0, sumSq/n - mean*mean);
    const std = Math.sqrt(variance);
    
    lastStats.mean = mean;
    lastStats.std = std;
    lastStats.price = heartbeatData[heartbeatData.length - 1].price;

    // 변동이 너무 작아도 화면에서 크게 보이도록 범위 강제로 확보
    // 기준: mean ± 3σ, 최소 범위는 mean의 0.1% 정도
    let minVal, maxVal;
    if (std > 0) {
        minVal = mean - 3*std;
        maxVal = mean + 3*std;
    } else {
        minVal = mean * 0.999;
        maxVal = mean * 1.001;
    }
    const minRange = mean * 0.001;
    const range = Math.max(maxVal - minVal, minRange);

    const currentPrice = heartbeatData[heartbeatData.length - 1].price;
    const priceFormatted = currentPrice.toLocaleString('ko-KR', {maximumFractionDigits: 0});

    /* 틱 속도 계산 (최근 1초간) */
    const oneSecondAgo = now - 1000;
    let tickCount = 0;
    for(let i = heartbeatData.length - 1; i >= 0; i--){
        if(heartbeatData[i].ts >= oneSecondAgo){
            tickCount++;
        }else{
            break;
        }
    }

    /* 데이터 품질 감시 - 동일 가격 연속 체크 */
    let samePriceCount = 1;
    let checkPrice = heartbeatData[heartbeatData.length - 1].price;
    for(let i = heartbeatData.length - 2; i >= Math.max(0, heartbeatData.length - 21); i--){
        if(Math.abs(heartbeatData[i].price - checkPrice) < 0.0001){
            samePriceCount++;
        }else{
            break;
        }
    }
    const isPriceStagnant = samePriceCount >= 20;

    /* 정보 표시 */
    let infoText = getStatusIcon(wsStatus) + ` 코인: ${heartbeatMarket} | 현재가: ${priceFormatted}원 | 최근 ${n}틱 | 평균≈${mean.toFixed(0)} | σ≈${std.toFixed(2)} | 틱속도: ${tickCount}/s`;
    if(isNoTrade){
        infoText += " | 무거래 상태";
    }
    if(isPriceStagnant){
        infoText += " | 가격 정체 구간";
    }
    heartbeatInfoEl.textContent = infoText;

    /* 변동성 임계치에 따른 색상 결정 */
    const volatilityRatio = std / mean;
    let volatilityLevel = "low";
    if(volatilityRatio >= 0.0015){
        volatilityLevel = "high";
    }else if(volatilityRatio >= 0.0005){
        volatilityLevel = "medium";
    }
    
    if(volatilityLevel === "high" && lastVolatilityLevel !== "high"){
        triggerObserver({
            market: heartbeatMarket,
            price: currentPrice,
            mean: mean,
            std: std,
            type: "high_volatility",
            ts: now
        });
    }
    lastVolatilityLevel = volatilityLevel;

    let mainColor1, mainColor2, mainColor3;
    if(isNoTrade){
        mainColor1 = "#888888";
        mainColor2 = "#666666";
        mainColor3 = "#555555";
    }else{
        if(volatilityRatio < 0.0005){
            mainColor1 = "#00ff88";
            mainColor2 = "#00d4ff";
            mainColor3 = "#00eaff";
        }else if(volatilityRatio < 0.0015){
            mainColor1 = "#ffd700";
            mainColor2 = "#ffaa00";
            mainColor3 = "#ff8800";
        }else{
            mainColor1 = "#ff4444";
            mainColor2 = "#ff6666";
            mainColor3 = "#ff8888";
        }
    }

    /* 네온 그라데이션 */
    const gradient = ctx.createLinearGradient(0,0,w,0);
    gradient.addColorStop(0, mainColor1);
    gradient.addColorStop(0.5, mainColor2);
    gradient.addColorStop(1, mainColor3);

    /* 잔상 효과 */
    const tail = 8;

    for(let t=tail; t>=0; t--){
        const alpha = (1 - t/tail) * 0.4;

        ctx.beginPath();
        ctx.lineWidth = 1.8;
        ctx.strokeStyle = `rgba(0,255,180,${alpha})`;
        ctx.shadowBlur = 3;
        ctx.shadowColor = `rgba(0,255,136,${alpha * 0.5})`;

        for(let i=1; i<heartbeatData.length - t; i++){
            const xPrev = ((i-1)/(heartbeatData.length-1))*(w-2)+1;
            const x     = (i/(heartbeatData.length-1))*(w-2)+1;

            const vPrev = heartbeatData[i-1].price;
            const v     = heartbeatData[i].price;

            const yPrev = h - 1 - ((vPrev - minVal)/range)*(h-2);
            const y     = h - 1 - ((v - minVal)/range)*(h-2);

            ctx.moveTo(xPrev, yPrev);
            ctx.lineTo(x, y);
        }
        ctx.stroke();
        ctx.shadowBlur = 0;
    }

    /* 평균선 표시 */
    const meanY = h - 1 - ((mean - minVal)/range)*(h-2);
    ctx.strokeStyle = "rgba(0, 255, 136, 0.4)";
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 4]);
    ctx.beginPath();
    ctx.moveTo(0, meanY);
    ctx.lineTo(w, meanY);
    ctx.stroke();
    ctx.setLineDash([]);

    /* 메인 네온 라인 */
    ctx.beginPath();
    ctx.lineWidth = 2.6;
    ctx.strokeStyle = gradient;
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    if(isNoTrade){
        ctx.shadowBlur = 4;
        ctx.shadowColor = "rgba(136, 136, 136, 0.3)";
    }else{
        ctx.shadowBlur = 8;
        ctx.shadowColor = "rgba(0, 255, 136, 0.6)";
    }

    for(let i=1; i<heartbeatData.length; i++){
        const xPrev = ((i-1)/(heartbeatData.length-1))*(w-2)+1;
        const x     = (i/(heartbeatData.length-1))*(w-2)+1;

        const vPrev = heartbeatData[i-1].price;
        const v     = heartbeatData[i].price;

        const yPrev = h - 1 - ((vPrev - minVal)/range)*(h-2);
        const y     = h - 1 - ((v - minVal)/range)*(h-2);

        ctx.moveTo(xPrev, yPrev);
        ctx.lineTo(x, y);
    }
    ctx.stroke();

    /* 스파이크 표시 */
    for(let i = 0; i < spikePoints.length; i++){
        const spike = spikePoints[i];
        if(spike.index >= 0 && spike.index < heartbeatData.length){
            const x = (spike.index/(heartbeatData.length-1))*(w-2)+1;
            const v = heartbeatData[spike.index].price;
            const y = h - 1 - ((v - minVal)/range)*(h-2);
            
            ctx.beginPath();
            ctx.arc(x, y, 3, 0, Math.PI * 2);
            if(spike.dir === 'up'){
                ctx.fillStyle = "#ff4444";
            }else{
                ctx.fillStyle = "#4444ff";
            }
            ctx.fill();
        }
    }
    
    // 그림자 초기화
    ctx.shadowBlur = 0;
}

/* ----------------------------------------------------
   WebSocket (안정화 버전)
---------------------------------------------------- */
let ws = null;
let wsTimer = null;

function processTicker(data){
    const market = data.cd || data.code;
    const price  = Number(data.tp || data.trade_price || 0);
    const ts     = Date.now();

    if(market === heartbeatMarket && price > 0){
        updateHeartbeat(ts, price);
    }
}

function connectWS(){
    if(ws){
        try { ws.close(); } catch(e){}
        ws = null;
    }

    wsStatus = "reconnecting";
    heartbeatInfoEl.textContent = getStatusIcon(wsStatus) + " WS 연결 시도 중...";
    console.log("[Heartbeat] WS connect...");

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

    ws.binaryType = "blob";

    ws.onopen = ()=>{
        console.log("[Heartbeat] WS opened");
        wsStatus = "connected";
        heartbeatInfoEl.textContent = getStatusIcon(wsStatus) + " WS 연결됨, 데이터 대기 중...";
        const msg = [
            { ticket: "price_only" },
            { type:"ticker", codes:[
                "KRW-BTC","KRW-ETH","KRW-XRP","KRW-QTUM","KRW-TRUMP"
            ]}
        ];
        ws.send(JSON.stringify(msg));
        requestRender();
    };

    ws.onmessage = (event)=>{
        if (event.data instanceof Blob) {
            const reader = new FileReader();
            reader.onload = () => {
                try{
                    const obj = JSON.parse(reader.result);
                    processTicker(obj);
                }catch(e){
                    console.error("[Heartbeat] JSON 파싱 오류", e);
                }
            };
            reader.readAsText(event.data);
        } else if (typeof event.data === "string") {
            try{
                const obj = JSON.parse(event.data);
                processTicker(obj);
            }catch(e){
                console.error("[Heartbeat] 문자열 JSON 파싱 오류", e);
            }
        }
    };

    ws.onerror = (e)=>{
        console.error("[Heartbeat] WS 오류", e);
        wsStatus = "error";
        heartbeatInfoEl.textContent = getStatusIcon(wsStatus) + " WS 오류 발생 (콘솔 확인)";
        requestRender();
    };

    ws.onclose = ()=>{
        console.warn("[Heartbeat] WS 종료, 재연결 예정");
        wsStatus = "reconnecting";
        heartbeatInfoEl.textContent = getStatusIcon(wsStatus) + " WS 종료, 3초 후 재연결 시도...";
        requestRender();
        if(wsTimer) clearTimeout(wsTimer);
        wsTimer = setTimeout(connectWS, 3000);
    };
}

connectWS();

// 상태 텍스트 갱신용
setInterval(()=>{
    if(heartbeatData.length >= 2){
        const now = Date.now();
        const isNoTrade = (!isFreeze) && (now - lastTickTime >= 2000);
        if(isNoTrade !== lastNoTradeState){
            lastNoTradeState = isNoTrade;
            requestRender();
        }
        if(isFreeze && now >= freezeEndTime){
            requestRender();
        }
    }
}, 500);
</script>

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