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