<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>실시간 업비트 거래량 차트</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<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;
--warning: #ffb300;
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}
body {
margin: 0;
padding: 0;
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;
transition: filter 0.3s ease;
}
body.night-mode {
filter: contrast(0.95) brightness(0.98);
}
body.day-mode {
filter: contrast(1.05) brightness(1.02);
}
header {
padding: 16px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-lg);
position: relative;
}
header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, #f87171, transparent);
opacity: 0;
transition: opacity 0.3s ease;
}
header.reconnecting::after {
opacity: 1;
animation: reconnectPulse 1.5s ease infinite;
}
@keyframes reconnectPulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
select {
background: rgba(15, 21, 37, 0.5);
color: rgba(226, 232, 240, 0.6);
padding: 8px 12px;
border: 1px solid rgba(42, 52, 88, 0.5);
border-radius: 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
select:hover {
background: rgba(18, 34, 104, 0.9);
border-color: rgba(59, 130, 246, 1);
}
select:focus {
outline: none;
border-color: rgba(59, 130, 246, 0.4);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.05);
}
#volumeBox {
margin-left: 16px;
font-size: 15px;
font-weight: bold;
color: var(--warning);
padding: 5px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--bg-border);
transition: color 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease, opacity 0.3s ease, filter 0.3s ease;
}
#volumeBox.flash {
background-color: rgba(255, 179, 0, 0.25);
box-shadow: 0 0 12px rgba(255, 179, 0, 0.4);
}
#volumeBox.dim {
color: #cc9000;
}
#volumeBox.stopped {
color: #888;
opacity: 0.5;
filter: grayscale(0.8);
}
#volumeBox.alert {
animation: borderBlink 0.4s ease infinite;
}
@keyframes borderBlink {
0%, 100% { border-color: var(--bg-border); }
50% { border-color: #ff4444; }
}
#volumeSpeed {
margin-left: 8px;
font-size: 12px;
font-weight: normal;
}
#volumeSpeed.up {
color: #4ade80;
}
#volumeSpeed.down {
color: #fbbf24;
}
#volumeSpeed.neutral {
color: #94a3b8;
}
.loading-text {
font-size: 12px;
color: rgba(226, 232, 240, 0.5);
}
#wsStatus {
font-size: 11px;
color: var(--text-muted);
margin-left: 12px;
}
#wsStatus.connected {
color: #4ade80;
}
#wsStatus.disconnected {
color: #f87171;
}
#wsStatus.reconnecting {
color: #fbbf24;
}
#tensionGauge {
width: 100px;
height: 6px;
background: rgba(42, 52, 88, 0.5);
border-radius: 3px;
margin-left: 12px;
position: relative;
overflow: hidden;
}
#tensionGaugeBar {
height: 100%;
background: linear-gradient(90deg, #4ade80, #fbbf24, #f87171);
width: 0%;
transition: width 0.5s ease;
border-radius: 3px;
}
#sensitivityControl {
margin-left: 8px;
display: flex;
gap: 4px;
}
#sensitivityControl button {
background: rgba(15, 21, 37, 0.5);
color: rgba(148, 163, 184, 0.6);
border: 1px solid rgba(42, 52, 88, 0.5);
border-radius: 4px;
padding: 4px 8px;
font-size: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
#sensitivityControl button.active {
background: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.5);
color: var(--text-primary);
}
#chart-wrap {
height: 700px;
padding: 20px;
background: var(--bg-secondary);
position: relative;
transition: transform 0.1s ease, filter 0.3s ease;
}
#chart-wrap.shake {
animation: chartShake 0.3s ease;
}
@keyframes chartShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
#chart-wrap.stopped {
filter: grayscale(0.6) contrast(0.9);
}
#chart-wrap::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(ellipse at center, rgba(255, 179, 0, 0.02) 0%, transparent 60%);
pointer-events: none;
z-index: 2;
}
#chartAlertOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 68, 68, 0.08);
pointer-events: none;
z-index: 3;
opacity: 0;
transition: opacity 0.3s ease;
}
#chartAlertOverlay.active {
opacity: 1;
}
#chartDenseBand {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 179, 0, 0.03);
pointer-events: none;
z-index: 2;
opacity: 0;
transition: opacity 0.3s ease;
}
#chartDenseBand.active {
opacity: 1;
}
#chartHeatZone {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(255, 179, 0, 0.05) 0%, transparent 50%);
pointer-events: none;
z-index: 2;
opacity: 0;
transition: opacity 0.5s ease;
}
#chartHeatZone.active {
opacity: 1;
}
#chartHoverHighlight {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
pointer-events: none;
z-index: 4;
opacity: 0;
transition: opacity 0.2s ease;
}
#chartHoverHighlight.active {
opacity: 1;
}
#chartCanvas {
width: 100%;
height: 100%;
background: linear-gradient(to bottom, #000000 0%, var(--bg-primary) 100%);
border-radius: 12px;
border: 1px solid var(--bg-border);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 1;
transition: filter 0.3s ease;
}
#sparkContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 5;
}
.spark {
position: absolute;
width: 4px;
height: 4px;
background: #ffb300;
border-radius: 50%;
pointer-events: none;
animation: sparkFade 0.6s ease-out forwards;
}
@keyframes sparkFade {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0) translate(var(--spark-x, 0), var(--spark-y, 0));
}
}
a {
background: rgba(15, 21, 37, 0.5);
color: rgba(148, 163, 184, 0.6);
border: 1px solid rgba(42, 52, 88, 0.5);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
text-decoration: none;
transition: all 0.2s ease;
}
a:hover {
background: rgba(18, 34, 104, 0.6);
color: rgba(226, 232, 240, 0.8);
border-color: rgba(255, 179, 0, 0.4);
}
#toggleEffects {
background: rgba(15, 21, 37, 0.5);
color: rgba(148, 163, 184, 0.6);
border: 1px solid rgba(42, 52, 88, 0.5);
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
#toggleEffects:hover {
background: rgba(18, 34, 104, 0.6);
border-color: rgba(255, 179, 0, 0.4);
}
#toggleEffects.active {
background: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.5);
color: var(--text-primary);
}
#eventLog {
position: fixed;
bottom: 20px;
right: 20px;
width: 250px;
max-height: 200px;
background: rgba(10, 14, 39, 0.95);
border: 1px solid var(--bg-border);
border-radius: 8px;
padding: 10px;
font-size: 11px;
color: var(--text-secondary);
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
#eventLog .event {
margin-bottom: 6px;
padding: 4px 8px;
border-left: 2px solid transparent;
border-radius: 3px;
}
#eventLog .event.surge {
border-left-color: #f87171;
background: rgba(248, 113, 113, 0.1);
}
#eventLog .event.stop {
border-left-color: #94a3b8;
background: rgba(148, 163, 184, 0.1);
}
#eventLog .event.reconnect {
border-left-color: #fbbf24;
background: rgba(251, 191, 36, 0.1);
}
#analysisGuide {
margin: 20px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--bg-border);
border-radius: 12px;
box-shadow: var(--shadow-lg);
}
#analysisGuide h3 {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 700;
color: var(--warning);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
}
#analysisGuide h3 button {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
#analysisGuide h3 button:hover {
border-color: var(--warning);
color: var(--warning);
}
#analysisGuideContent {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
#analysisGuideContent.collapsed {
display: none;
}
.analysis-item {
padding: 12px;
background: rgba(15, 21, 37, 0.3);
border-left: 3px solid var(--warning);
border-radius: 6px;
}
.analysis-item h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.analysis-item p {
margin: 4px 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.analysis-item .method {
color: var(--warning);
font-weight: 500;
}
.analysis-item .analysis {
color: #4ade80;
}
.analysis-item .usage {
color: #3b82f6;
font-style: italic;
}
::-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);
}
</style>
</head>
<body>
<?php
// ===== 업비트 마켓 목록 =====
$api = 'https://api.upbit.com/v1/market/all';
$data = json_decode(file_get_contents($api), true);
if ($data === null) $data = [];
// ===== 파라미터 =====
$market = $_GET['market'] ?? 'KRW-BTC';
$chart_time = isset($_GET['time']) ? (int)$_GET['time'] : 0;
// ===== 마켓 검증 =====
$validMarket = 'KRW-BTC';
foreach ($data as $m) {
if ($m['market'] === $market && strpos($m['market'], 'KRW-') === 0) {
$validMarket = $market;
break;
}
}
?>
<header>
<h2>Real-Time Volume Line Chart</h2>
<div style="display:flex; align-items:center; gap:8px;">
<!-- 시간 버튼 (HTML 링크) -->
<a href="?market=<?= $validMarket ?>&time=1">1분</a>
<a href="?market=<?= $validMarket ?>&time=5">5분</a>
<a href="?market=<?= $validMarket ?>&time=15">15분</a>
<a href="?market=<?= $validMarket ?>&time=60">1시간</a>
<a href="?market=<?= $validMarket ?>&time=240">4시간</a>
<a href="?market=<?= $validMarket ?>&time=480">8시간</a>
<a href="?market=<?= $validMarket ?>&time=720">12시간</a>
<a href="?market=<?= $validMarket ?>&time=1440">24시간</a>
<!-- 코인 셀렉트 -->
<select onchange="location.href='?market='+this.value+'&time=<?= $chart_time ?>'">
<?php
foreach ($data as $m) {
if (strpos($m['market'], 'KRW-') !== 0) continue;
$symbol = str_replace('KRW-', '', $m['market']);
$korean = $m['korean_name'];
$selected = ($m['market'] === $validMarket) ? ' selected' : '';
echo "<option value=\"{$m['market']}\"{$selected}>{$symbol} : {$korean}</option>\n";
}
?>
</select>
<div id="volumeBox"><span class="loading-text">로딩 중...</span></div>
<span id="volumeSpeed"></span>
<div id="tensionGauge"><div id="tensionGaugeBar"></div></div>
<div id="sensitivityControl">
<button onclick="setSensitivity('low', event)">둔감</button>
<button onclick="setSensitivity('normal', event)">보통</button>
<button onclick="setSensitivity('high', event)">민감</button>
</div>
<span id="wsStatus" class="disconnected">연결 중...</span>
<button id="toggleEffects" onclick="toggleEffects()">효과 ON</button>
</div>
</header>
<div id="chart-wrap">
<div id="chartAlertOverlay"></div>
<div id="chartDenseBand"></div>
<div id="chartHeatZone"></div>
<div id="chartHoverHighlight"></div>
<div id="sparkContainer"></div>
<canvas id="chartCanvas"></canvas>
</div>
<div id="analysisGuide">
<h3>
차트 분석 방법 가이드
<button onclick="toggleGuide()">접기/펼치기</button>
</h3>
<div id="analysisGuideContent">
<div class="analysis-item">
<h4>1. 색상 스펙트럼 분석</h4>
<p><span class="method">방법:</span> 차트 선 색상 관찰</p>
<p><span class="analysis">분석:</span> 어두운색(낮음) → 노란색(중간) → 주황색(높음) → 적색(매우높음)</p>
<p><span class="usage">활용:</span> 색상 변화로 거래량 강도 파악</p>
</div>
<div class="analysis-item">
<h4>2. 선 두께 분석</h4>
<p><span class="method">방법:</span> 차트 선 두께 관찰</p>
<p><span class="analysis">분석:</span> 1px(최소) → 2-3px(중간) → 4px(최대)</p>
<p><span class="usage">활용:</span> 두께 변화로 거래량 크기 추정</p>
</div>
<div class="analysis-item">
<h4>3. 긴장도 게이지 분석</h4>
<p><span class="method">방법:</span> header의 긴장도 바 확인</p>
<p><span class="analysis">분석:</span> 0-30%(안정) → 30-60%(보통) → 60-100%(높은 변동성)</p>
<p><span class="usage">활용:</span> 최근 10포인트 분산 기반 변동성 파악</p>
</div>
<div class="analysis-item">
<h4>4. 거래량 속도 분석</h4>
<p><span class="method">방법:</span> volumeSpeed 요소 확인</p>
<p><span class="analysis">분석:</span> ▲X%(증가, 초록) / ▼X%(감소, 노랑)</p>
<p><span class="usage">활용:</span> 단기 변화율 파악</p>
</div>
<div class="analysis-item">
<h4>5. 연속 증가/감소 패턴</h4>
<p><span class="method">방법:</span> 차트 선 색상 농도 관찰</p>
<p><span class="analysis">분석:</span> 연속 3회 이상 증가 시 색상 밝아짐, 감소 시 탈색</p>
<p><span class="usage">활용:</span> 추세 지속성 판단</p>
</div>
<div class="analysis-item">
<h4>6. 급등 감지 분석</h4>
<p><span class="method">방법:</span> 스파크 효과 및 이벤트 로그 확인</p>
<p><span class="analysis">분석:</span> 1.5배 이상 급등 시 스파크 효과 발생</p>
<p><span class="usage">활용:</span> 비정상적 급등 포착</p>
</div>
<div class="analysis-item">
<h4>7. 거래량 급증 경고</h4>
<p><span class="method">방법:</span> 차트 배경 오버레이 및 volumeBox 테두리 점멸 확인</p>
<p><span class="analysis">분석:</span> 최근 10포인트 평균 대비 2.5배 이상 시 경고</p>
<p><span class="usage">활용:</span> 비정상적 급증 알림</p>
</div>
<div class="analysis-item">
<h4>8. 밀집 구간 분석</h4>
<p><span class="method">방법:</span> chartDenseBand 배경 표시 확인</p>
<p><span class="analysis">분석:</span> 최근 30포인트 중 상위 20% 구간이 15% 이상일 때 활성화</p>
<p><span class="usage">활용:</span> 거래량 집중 구간 파악</p>
</div>
<div class="analysis-item">
<h4>9. 히트존 분석</h4>
<p><span class="method">방법:</span> chartHeatZone 배경 표시 확인</p>
<p><span class="analysis">분석:</span> 현재 거래량이 최근 30포인트 상위 30% 평균의 80% 이상</p>
<p><span class="usage">활용:</span> 고거래량 구간 식별</p>
</div>
<div class="analysis-item">
<h4>10. 최대값 마커 분석</h4>
<p><span class="method">방법:</span> 차트 상 큰 점(pointRadius: 5) 확인</p>
<p><span class="analysis">분석:</span> 현재 화면(MAX_POINTS) 내 최대 거래량 위치 표시</p>
<p><span class="usage">활용:</span> 최고 거래량 시점 파악</p>
</div>
<div class="analysis-item">
<h4>11. 데이터 정지 감지</h4>
<p><span class="method">방법:</span> volumeBox "정지" 표시 및 차트 채도 확인</p>
<p><span class="analysis">분석:</span> 3초 이상 데이터 없음 시 "정지" 표시, 차트 채도 낮아짐</p>
<p><span class="usage">활용:</span> 연결 상태 및 데이터 수신 상태 확인</p>
</div>
<div class="analysis-item">
<h4>12. 이벤트 로그 분석</h4>
<p><span class="method">방법:</span> 우측 하단 eventLog 패널 확인</p>
<p><span class="analysis">분석:</span> 급증(빨강) / 정지(회색) / 재연결(노랑)</p>
<p><span class="usage">활용:</span> 주요 이벤트 시간대 파악</p>
</div>
<div class="analysis-item">
<h4>13. 마우스 오버 분석</h4>
<p><span class="method">방법:</span> 차트에 마우스 오버 시 디밍 효과 확인</p>
<p><span class="analysis">분석:</span> 특정 구간 오버 시 해당 구간 강조, 나머지 디밍</p>
<p><span class="usage">활용:</span> 특정 시점 상세 확인</p>
</div>
<div class="analysis-item">
<h4>14. 감도 조절 분석</h4>
<p><span class="method">방법:</span> "둔감/보통/민감" 버튼으로 감도 조절</p>
<p><span class="analysis">분석:</span> 둔감(0.5배) / 보통(1.0배) / 민감(2.0배)</p>
<p><span class="usage">활용:</span> 분석 목적에 맞는 감도 설정</p>
</div>
<div class="analysis-item">
<h4>15. 시간대별 분석</h4>
<p><span class="method">방법:</span> 시간 버튼(1분~24시간) 선택</p>
<p><span class="analysis">분석:</span> 단기(1-15분) / 중기(1-4시간) / 장기(8-24시간)</p>
<p><span class="usage">활용:</span> 분석 기간에 맞는 시간 단위 선택</p>
</div>
<div class="analysis-item">
<h4>16. 효과 ON/OFF 비교</h4>
<p><span class="method">방법:</span> "효과 ON/OFF" 버튼으로 전환</p>
<p><span class="analysis">분석:</span> ON(모든 시각 효과) / OFF(기본 차트만)</p>
<p><span class="usage">활용:</span> 원시 데이터와 시각화 데이터 비교</p>
</div>
<div class="analysis-item">
<h4>17. 연결 상태 모니터링</h4>
<p><span class="method">방법:</span> header의 wsStatus 확인</p>
<p><span class="analysis">분석:</span> 연결됨(초록) / 끊김(빨강) / 재연결 중(노랑)</p>
<p><span class="usage">활용:</span> 데이터 수신 안정성 확인</p>
</div>
</div>
</div>
<div id="eventLog"></div>
<script>
let MARKET = "<?= $validMarket ?>";
let UNIT_MIN = <?= $chart_time ?>;
let ws = null;
const MAX_POINTS = 300;
const STOP_THRESHOLD = 3000;
const TENSION_UPDATE_INTERVAL = 500;
const SPARK_THRESHOLD = 1.5;
const ALERT_AVG_POINTS = 10;
const ALERT_MULTIPLIER = 2.5;
const DENSE_POINTS = 30;
let flashTimer = null;
let lastDataTime = Date.now();
let dataStopTimer = null;
let reconnectTimer = null;
let effectsEnabled = localStorage.getItem('volumeEffects') !== 'false';
let maxVolIndex = -1;
let alertOverlayTimer = null;
let checkDataStopInterval = null;
let timeModeInterval = null;
let sensitivity = localStorage.getItem('volumeSensitivity') || 'normal';
let consecutiveUp = 0;
let consecutiveDown = 0;
let dataUpdateTimes = [];
let stopEventLogged = false;
let lastTensionUpdate = 0;
let activeSparks = [];
let sparkTimers = [];
let guideCollapsed = localStorage.getItem('guideCollapsed') === 'true';
function toggleGuide() {
guideCollapsed = !guideCollapsed;
localStorage.setItem('guideCollapsed', guideCollapsed);
const content = document.getElementById('analysisGuideContent');
if (content) {
content.classList.toggle('collapsed', guideCollapsed);
}
}
function getLabel(unitMin) {
const d = new Date();
if (unitMin === 0) {
return d.toTimeString().split(" ")[0]; // 실시간
}
const h = String(d.getHours()).padStart(2, '0');
const mm = String(Math.floor(d.getMinutes() / unitMin) * unitMin).padStart(2, '0');
return h + ':' + mm;
}
function updateTimeMode() {
if (!document.body) return;
const hour = new Date().getHours();
document.body.classList.remove('night-mode', 'day-mode');
if (hour >= 6 && hour < 18) {
document.body.classList.add('day-mode');
} else {
document.body.classList.add('night-mode');
}
}
function setSensitivity(level, evt) {
sensitivity = level;
localStorage.setItem('volumeSensitivity', level);
const buttons = document.querySelectorAll('#sensitivityControl button');
buttons.forEach(btn => {
btn.classList.remove('active');
});
if (evt && evt.target) {
evt.target.classList.add('active');
} else {
buttons.forEach(btn => {
if ((level === 'low' && btn.textContent === '둔감') ||
(level === 'normal' && btn.textContent === '보통') ||
(level === 'high' && btn.textContent === '민감')) {
btn.classList.add('active');
}
});
}
}
function getSensitivityMultiplier() {
if (sensitivity === 'low') return 0.5;
if (sensitivity === 'high') return 2.0;
return 1.0;
}
function addEventLog(type, message) {
const log = document.getElementById('eventLog');
if (!log) return;
const event = document.createElement('div');
event.className = `event ${type}`;
const time = new Date().toLocaleTimeString();
event.textContent = `[${time}] ${message}`;
log.insertBefore(event, log.firstChild);
while (log.children.length > 10) {
log.removeChild(log.lastChild);
}
log.scrollTop = 0;
}
function createSpark(x, y) {
if (!effectsEnabled) return;
const container = document.getElementById('sparkContainer');
if (!container) return;
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
const rect = container.getBoundingClientRect();
const sparkX = x - rect.left - scrollX;
const sparkY = y - rect.top - scrollY;
for (let i = 0; i < 8; i++) {
const spark = document.createElement('div');
spark.className = 'spark';
const angle = (Math.PI * 2 * i) / 8;
const distance = 20 + Math.random() * 30;
spark.style.left = sparkX + 'px';
spark.style.top = sparkY + 'px';
spark.style.setProperty('--spark-x', Math.cos(angle) * distance + 'px');
spark.style.setProperty('--spark-y', Math.sin(angle) * distance + 'px');
container.appendChild(spark);
activeSparks.push(spark);
const timer = setTimeout(() => {
if (spark.parentNode) {
spark.remove();
}
const index = activeSparks.indexOf(spark);
if (index > -1) {
activeSparks.splice(index, 1);
}
const timerIndex = sparkTimers.indexOf(timer);
if (timerIndex > -1) {
sparkTimers.splice(timerIndex, 1);
}
}, 600);
sparkTimers.push(timer);
}
}
function getVolumeColor(vol, minVol, maxVol) {
if (!effectsEnabled || maxVol === minVol) return "#ffb300";
const ratio = (vol - minVol) / (maxVol - minVol);
const sensMult = getSensitivityMultiplier();
const adjRatio = Math.min(1, ratio * sensMult);
if (adjRatio < 0.33) return "#cc9000";
if (adjRatio < 0.66) return "#ffb300";
if (adjRatio < 0.85) return "#ff8800";
return "#ff4444";
}
function getVolumeWidth(vol, minVol, maxVol) {
if (!effectsEnabled || maxVol === minVol) return 2;
const ratio = (vol - minVol) / (maxVol - minVol);
const sensMult = getSensitivityMultiplier();
const adjRatio = Math.min(1, ratio * sensMult);
return 1 + (adjRatio * 3);
}
function toggleEffects() {
effectsEnabled = !effectsEnabled;
localStorage.setItem('volumeEffects', effectsEnabled);
const btn = document.getElementById('toggleEffects');
if (btn) {
btn.textContent = effectsEnabled ? '효과 ON' : '효과 OFF';
btn.classList.toggle('active', effectsEnabled);
}
if (!effectsEnabled) {
chartData.datasets[0].borderColor = "#ffb300";
chartData.datasets[0].borderWidth = 2;
const chartWrap = document.getElementById('chart-wrap');
if (chartWrap) chartWrap.classList.remove('shake', 'stopped');
}
}
function updateWSStatus(status) {
const statusEl = document.getElementById('wsStatus');
if (!statusEl) return;
statusEl.className = status;
if (status === 'connected') statusEl.textContent = '연결됨';
else if (status === 'disconnected') statusEl.textContent = '끊김';
else if (status === 'reconnecting') {
statusEl.textContent = '재연결 중...';
const header = document.querySelector('header');
if (header) header.classList.add('reconnecting');
addEventLog('reconnect', '재연결 시도 중...');
}
if (status === 'connected') {
const header = document.querySelector('header');
if (header) header.classList.remove('reconnecting');
}
}
const ctx = document.getElementById("chartCanvas").getContext("2d");
const bgGradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height || 700);
bgGradient.addColorStop(0, 'rgba(255, 179, 0, 0.12)');
bgGradient.addColorStop(0.5, 'rgba(255, 179, 0, 0.04)');
bgGradient.addColorStop(1, 'rgba(255, 179, 0, 0)');
const chartData = {
labels: [],
datasets: [{
label: MARKET + " 거래량",
data: [],
borderColor: "#ffb300",
backgroundColor: bgGradient,
borderWidth: 2,
pointRadius: 0,
tension: 0.1,
fill: true,
maxIndex: -1
}]
};
const chart = new Chart(ctx, {
type: "line",
data: chartData,
options: {
animation:false,
responsive:true,
maintainAspectRatio:false,
scales:{
x:{ ticks:{color:"#888"}, grid:{color:"rgba(255,255,255,0.07)"} },
y:{ ticks:{color:"#ccc"}, grid:{color:"rgba(255,255,255,0.07)"} }
},
plugins:{
legend:{ labels:{color:"#ddd"} },
tooltip:{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: '#ffb300',
bodyColor: '#fff',
borderColor: '#ffb300',
borderWidth: 1,
padding: 10,
displayColors: false,
callbacks:{
label: c => c.raw.toLocaleString() + " 수량"
}
}
},
elements: {
point: {
radius: function(context) {
if (!effectsEnabled) return 0;
const index = context.dataIndex;
const dataset = context.dataset;
if (dataset.maxIndex === index) return 5;
return 0;
}
},
line: {
borderWidth: function(context) {
if (!effectsEnabled) return 2;
const dataset = context.dataset;
const data = dataset.data;
if (!data || data.length === 0) return 2;
const min = Math.min(...data);
const max = Math.max(...data);
if (min === max) return 2;
const val = data[context.dataIndex];
if (val === undefined) return 2;
return getVolumeWidth(val, min, max);
},
borderColor: function(context) {
if (!effectsEnabled) return "#ffb300";
const dataset = context.dataset;
const data = dataset.data;
if (!data || data.length === 0) return "#ffb300";
const min = Math.min(...data);
const max = Math.max(...data);
if (min === max) return "#ffb300";
const val = data[context.dataIndex];
if (val === undefined) return "#ffb300";
return getVolumeColor(val, min, max);
}
}
},
onHover: (event, activeElements) => {
const highlight = document.getElementById('chartHoverHighlight');
if (!highlight) return;
if (activeElements && activeElements.length > 0) {
highlight.classList.add('active');
} else {
highlight.classList.remove('active');
}
}
}
});
function checkDataStop() {
const elapsed = Date.now() - lastDataTime;
const volBox = document.getElementById("volumeBox");
const chartWrap = document.getElementById("chart-wrap");
if (!volBox) return;
if (elapsed > STOP_THRESHOLD) {
volBox.classList.add("stopped");
if (chartWrap) chartWrap.classList.add("stopped");
const currentText = volBox.textContent || '';
if (currentText.indexOf("정지") === -1 && currentText.indexOf("수량") !== -1) {
volBox.textContent = currentText.replace(" 수량", " 정지");
if (!stopEventLogged) {
addEventLog('stop', '데이터 수신 정지');
stopEventLogged = true;
}
}
} else {
volBox.classList.remove("stopped");
if (chartWrap) chartWrap.classList.remove("stopped");
const currentText = volBox.textContent || '';
if (currentText.indexOf("정지") !== -1) {
volBox.textContent = currentText.replace(" 정지", " 수량");
stopEventLogged = false;
}
}
}
function updateTensionGauge() {
if (!effectsEnabled) return;
const now = Date.now();
if (now - lastTensionUpdate < TENSION_UPDATE_INTERVAL) return;
lastTensionUpdate = now;
const dataArr = chartData.datasets[0].data;
if (dataArr.length < 10) return;
const recent = dataArr.slice(-10);
let variance = 0;
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
recent.forEach(v => {
variance += Math.pow(v - avg, 2);
});
variance = variance / recent.length;
const maxVar = Math.pow(avg, 2) * 0.1;
const tension = Math.min(100, Math.max(0, (variance / maxVar) * 100));
const gaugeBar = document.getElementById('tensionGaugeBar');
if (gaugeBar) gaugeBar.style.width = tension + '%';
}
function processVolumeData(vol) {
if (!effectsEnabled) return;
const dataArr = chartData.datasets[0].data;
const len = dataArr.length;
if (len === 0) return;
const prevVol = dataArr[len - 1];
const speedEl = document.getElementById("volumeSpeed");
if (speedEl && prevVol > 0 && typeof vol === 'number' && vol > 0) {
const change = ((vol - prevVol) / prevVol) * 100;
if (change > 0.1) {
speedEl.textContent = `▲${change.toFixed(1)}%`;
speedEl.className = "up";
consecutiveUp++;
consecutiveDown = 0;
} else if (change < -0.1) {
speedEl.textContent = `▼${Math.abs(change).toFixed(1)}%`;
speedEl.className = "down";
consecutiveDown++;
consecutiveUp = 0;
} else {
speedEl.textContent = "";
speedEl.className = "neutral";
consecutiveUp = 0;
consecutiveDown = 0;
}
}
if (prevVol > 0 && typeof vol === 'number' && vol > 0 && vol / prevVol > SPARK_THRESHOLD) {
const chartCanvas = document.getElementById("chartCanvas");
const chartWrap = document.getElementById("chart-wrap");
if (chartCanvas && chartWrap) {
const canvasRect = chartCanvas.getBoundingClientRect();
const wrapRect = chartWrap.getBoundingClientRect();
const x = wrapRect.left + wrapRect.width * 0.9;
const y = wrapRect.top + wrapRect.height * 0.3;
createSpark(x, y);
addEventLog('surge', '급등 감지');
}
}
if (len >= ALERT_AVG_POINTS) {
const recent = dataArr.slice(-ALERT_AVG_POINTS);
const avg = recent.reduce((a, b) => a + b, 0) / ALERT_AVG_POINTS;
if (typeof vol === 'number' && vol > avg * ALERT_MULTIPLIER) {
const overlay = document.getElementById("chartAlertOverlay");
if (overlay) {
overlay.classList.add("active");
const volBox = document.getElementById("volumeBox");
if (volBox) volBox.classList.add("alert");
if (alertOverlayTimer) clearTimeout(alertOverlayTimer);
alertOverlayTimer = setTimeout(() => {
if (overlay) overlay.classList.remove("active");
if (volBox) volBox.classList.remove("alert");
}, 2000);
}
const chartWrap = document.getElementById("chart-wrap");
if (chartWrap && effectsEnabled) {
chartWrap.classList.add("shake");
setTimeout(() => chartWrap.classList.remove("shake"), 300);
}
}
}
if (len >= DENSE_POINTS) {
const recent = dataArr.slice(-DENSE_POINTS);
const sorted = [...recent].sort((a, b) => b - a);
const threshold = sorted[Math.floor(DENSE_POINTS * 0.2)];
let denseCount = 0;
for (let i = len - DENSE_POINTS; i < len; i++) {
if (dataArr[i] >= threshold) denseCount++;
}
const denseBand = document.getElementById("chartDenseBand");
if (denseBand) {
if (denseCount >= DENSE_POINTS * 0.15) {
denseBand.classList.add("active");
} else {
denseBand.classList.remove("active");
}
}
const heatZone = document.getElementById("chartHeatZone");
if (heatZone) {
const topValues = sorted.slice(0, Math.floor(DENSE_POINTS * 0.3));
const avgTop = topValues.reduce((a, b) => a + b, 0) / topValues.length;
if (typeof vol === 'number' && vol >= avgTop * 0.8) {
heatZone.classList.add("active");
} else {
heatZone.classList.remove("active");
}
}
}
if (len > 0) {
let maxVal = dataArr[0];
let maxIdx = 0;
for (let i = 1; i < len; i++) {
if (dataArr[i] > maxVal) {
maxVal = dataArr[i];
maxIdx = i;
}
}
maxVolIndex = maxIdx;
chartData.datasets[0].maxIndex = maxIdx;
}
updateTensionGauge();
const minVol = Math.min(...dataArr);
const maxVol = Math.max(...dataArr);
let currentColor = getVolumeColor(vol, minVol, maxVol);
if (consecutiveUp >= 3) {
const intensity = Math.min(consecutiveUp / 10, 0.3);
const g = Math.min(255, Math.floor(179 + intensity * 76));
currentColor = `rgba(255, ${g}, 0, 1)`;
}
if (consecutiveDown >= 3) {
const intensity = Math.min(consecutiveDown / 10, 0.5);
const g = Math.max(100, Math.floor(144 - intensity * 44));
const a = Math.max(0.5, 1 - intensity);
currentColor = `rgba(204, ${g}, 0, ${a})`;
}
chartData.datasets[0].borderColor = currentColor;
}
function connectWS() {
if (ws && ws.readyState === WebSocket.OPEN) return;
if (ws && ws.readyState !== WebSocket.CLOSED) {
try { ws.close(); } catch(e) {}
}
updateWSStatus('reconnecting');
ws = new WebSocket("wss://api.upbit.com/websocket/v1");
ws.onopen = () => {
updateWSStatus('connected');
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ws.send(JSON.stringify([
{ ticket:"volume" },
{ type:"ticker", codes:[MARKET] }
]));
};
ws.onclose = () => {
updateWSStatus('disconnected');
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => connectWS(), 3000);
};
ws.onerror = () => {
updateWSStatus('disconnected');
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => connectWS(), 3000);
};
ws.onmessage = e => {
const reader = new FileReader();
reader.onload = () => {
try {
const d = JSON.parse(reader.result);
if (!d || typeof d.acc_trade_volume_24h === 'undefined') return;
const vol = d.acc_trade_volume_24h;
if (typeof vol !== 'number' || vol < 0) return;
const time = getLabel(UNIT_MIN);
const now = Date.now();
lastDataTime = now;
dataUpdateTimes.push(now);
if (dataUpdateTimes.length > 10) dataUpdateTimes.shift();
if (dataStopTimer) clearTimeout(dataStopTimer);
dataStopTimer = setTimeout(checkDataStop, STOP_THRESHOLD);
const volBox = document.getElementById("volumeBox");
if (volBox) {
volBox.textContent = vol.toLocaleString() + " 수량";
volBox.classList.remove("stopped");
}
const dataArr = chartData.datasets[0].data;
if (dataArr.length > 0) {
const prevVol = dataArr[dataArr.length - 1];
if (vol > prevVol) {
if (volBox) {
volBox.classList.remove("dim", "stopped");
volBox.classList.add("flash");
}
if (flashTimer) clearTimeout(flashTimer);
flashTimer = setTimeout(() => {
if (volBox) volBox.classList.remove("flash");
}, 300);
const minVol = Math.min(...dataArr);
const maxVol = Math.max(...dataArr);
if (minVol !== maxVol) {
chartData.datasets[0].borderColor = getVolumeColor(vol, minVol, maxVol);
}
} else if (vol < prevVol) {
if (volBox) {
volBox.classList.remove("flash", "stopped");
volBox.classList.add("dim");
}
if (flashTimer) clearTimeout(flashTimer);
const minVol = Math.min(...dataArr);
const maxVol = Math.max(...dataArr);
if (minVol !== maxVol) {
chartData.datasets[0].borderColor = getVolumeColor(vol, minVol, maxVol);
}
} else {
if (volBox) {
volBox.classList.remove("flash", "dim", "stopped");
}
if (flashTimer) clearTimeout(flashTimer);
const minVol = Math.min(...dataArr);
const maxVol = Math.max(...dataArr);
if (minVol !== maxVol) {
chartData.datasets[0].borderColor = getVolumeColor(vol, minVol, maxVol);
}
}
}
chartData.labels.push(time);
chartData.datasets[0].data.push(vol);
if (chartData.labels.length > MAX_POINTS) {
chartData.labels.shift();
chartData.datasets[0].data.shift();
maxVolIndex = -1;
chartData.datasets[0].maxIndex = -1;
}
processVolumeData(vol);
chart.update("none");
} catch(err) {
console.error('Data parse error:', err);
}
};
reader.readAsText(e.data);
};
}
if (document.body) {
updateTimeMode();
} else {
document.addEventListener('DOMContentLoaded', updateTimeMode);
}
timeModeInterval = setInterval(updateTimeMode, 3600000);
const toggleBtn = document.getElementById('toggleEffects');
if (toggleBtn) {
toggleBtn.textContent = effectsEnabled ? '효과 ON' : '효과 OFF';
toggleBtn.classList.toggle('active', effectsEnabled);
}
document.querySelectorAll('#sensitivityControl button').forEach(btn => {
if ((sensitivity === 'low' && btn.textContent === '둔감') ||
(sensitivity === 'normal' && btn.textContent === '보통') ||
(sensitivity === 'high' && btn.textContent === '민감')) {
btn.classList.add('active');
}
});
const guideContent = document.getElementById('analysisGuideContent');
if (guideContent) {
guideContent.classList.toggle('collapsed', guideCollapsed);
}
connectWS();
checkDataStopInterval = setInterval(checkDataStop, 1000);
window.addEventListener('beforeunload', () => {
if (checkDataStopInterval) clearInterval(checkDataStopInterval);
if (timeModeInterval) clearInterval(timeModeInterval);
if (flashTimer) clearTimeout(flashTimer);
if (dataStopTimer) clearTimeout(dataStopTimer);
if (reconnectTimer) clearTimeout(reconnectTimer);
if (alertOverlayTimer) clearTimeout(alertOverlayTimer);
sparkTimers.forEach(timer => clearTimeout(timer));
activeSparks.forEach(spark => {
if (spark.parentNode) spark.remove();
});
if (ws) {
try { ws.close(); } catch(e) {}
}
});
</script>
</body>
</html>