<?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'; ?>