GNU/_PAGE/chart/upbit/ecg/Price_mini_basic.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;
    --heartbeat-color: #00ff88;
    --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;
    margin-left: 6px;
}

#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: 70px;
    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%;
    }
}
</style>
</head>
<body>

<h2>📉 가격 심전도 - 미니 베이직 버전</h2>

<div id="heartbeat-container">
    <div id="heartbeat-title">
        <span>가격 심전도 (Price Heartbeat) Mini Basic</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="70"></canvas>
</div>

<script>
const HEARTBEAT_POINTS = 120;
let heartbeatMarket = "KRW-BTC";
let heartbeatData = [];

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

/* 캔버스 폭을 컨테이너 width에 맞게 자동 조정 */
function resizeHeartbeatCanvas() {
    const rect = heartbeatCanvas.getBoundingClientRect();
    heartbeatCanvas.width = rect.width;   // 가로만 반응형
    drawHeartbeat();
}
resizeHeartbeatCanvas();
window.addEventListener('resize', resizeHeartbeatCanvas);

heartbeatSelect.addEventListener('change', () => {
    heartbeatMarket = heartbeatSelect.value;
    heartbeatData = [];
    heartbeatInfoEl.textContent = `심전도: ${heartbeatMarket} 변경, 데이터 대기 중...`;
    drawHeartbeat();
});

function updateHeartbeat(ts, price) {
    heartbeatData.push({ ts, price });
    while (heartbeatData.length > HEARTBEAT_POINTS) {
        heartbeatData.shift();
    }
    drawHeartbeat();
}

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

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

    /* 배경 그리드 */
    ctx.strokeStyle = "rgba(255,255,255,0.05)";
    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 = "WS 연결 준비 중...";
        return;
    }

    /* 평균, 표준편차 */
    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);

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

    heartbeatInfoEl.textContent =
        `코인: ${heartbeatMarket} | 최근 ${n}틱 | 평균≈${mean.toFixed(0)} | σ≈${std.toFixed(2)}`;

    /* 네온 그라데이션 */
    const gradient = ctx.createLinearGradient(0, 0, w, 0);
    gradient.addColorStop(0, "#00ff88");
    gradient.addColorStop(1, "#00eaff");

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

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

        ctx.beginPath();
        ctx.lineWidth = 1.6;
        ctx.strokeStyle = `rgba(0,255,180,${alpha})`;

        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.beginPath();
    ctx.lineWidth = 2.4;
    ctx.strokeStyle = gradient;
    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();
    
    // 그림자 초기화
    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;
    }

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

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

    ws.binaryType = "blob"; // Upbit는 바이너리로 옴

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

    ws.onmessage = (event) => {
        // 안정적인 Blob → 텍스트 변환
        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);
        heartbeatInfoEl.textContent = "WS 오류 발생 (콘솔 확인)";
    };

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

connectWS();
</script>

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