atomsystem 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useId } from 'react';
4
+ import { Play, Pause, TrendingUp, TrendingDown, Clock, Activity, RefreshCw } from 'lucide-react';
5
+
6
+ interface ChartPoint {
7
+ time: string;
8
+ price: number;
9
+ high: number;
10
+ low: number;
11
+ volume: number;
12
+ }
13
+
14
+ const INITIAL_POINTS: ChartPoint[] = [
15
+ { time: '09:00', price: 82400, high: 82600, low: 82100, volume: 1420 },
16
+ { time: '09:05', price: 82900, high: 83100, low: 82500, volume: 2100 },
17
+ { time: '09:10', price: 82700, high: 83000, low: 82600, volume: 1850 },
18
+ { time: '09:15', price: 83400, high: 83600, low: 82700, volume: 3200 },
19
+ { time: '09:20', price: 83900, high: 84200, low: 83300, volume: 4100 },
20
+ { time: '09:25', price: 83600, high: 84000, low: 83400, volume: 2600 },
21
+ { time: '09:30', price: 84200, high: 84500, low: 83600, volume: 4800 },
22
+ { time: '09:35', price: 84800, high: 85100, low: 84100, volume: 5300 },
23
+ { time: '09:40', price: 84600, high: 84900, low: 84400, volume: 3100 },
24
+ { time: '09:45', price: 85300, high: 85600, low: 84600, volume: 6200 },
25
+ ];
26
+
27
+ export function RealtimeChartBlock() {
28
+ const [data, setData] = useState<ChartPoint[]>(INITIAL_POINTS);
29
+ const [isPlaying, setIsPlaying] = useState(true);
30
+ const [chartType, setChartType] = useState<'line' | 'candle'>('line');
31
+ const [timeframe, setTimeframe] = useState<'1m' | '5m' | '1h' | '1d'>('1m');
32
+ const [hoveredPoint, setHoveredPoint] = useState<ChartPoint | null>(null);
33
+ const [tickFlash, setTickFlash] = useState<'up' | 'down' | null>(null);
34
+
35
+ const gradientId = useId();
36
+
37
+ // 실시간 틱 데이터 시뮬레이션
38
+ useEffect(() => {
39
+ if (!isPlaying) return;
40
+ const interval = setInterval(() => {
41
+ setData((prev) => {
42
+ const last = prev[prev.length - 1];
43
+ const delta = (Math.random() - 0.48) * 350;
44
+ const newPrice = Math.round(last.price + delta);
45
+ const newHigh = Math.max(newPrice, last.high);
46
+ const newLow = Math.min(newPrice, last.low);
47
+ const newVolume = Math.round(2000 + Math.random() * 4000);
48
+
49
+ setTickFlash(delta >= 0 ? 'up' : 'down');
50
+ setTimeout(() => setTickFlash(null), 400);
51
+
52
+ const now = new Date();
53
+ const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
54
+
55
+ const newPoint: ChartPoint = {
56
+ time: timeStr,
57
+ price: newPrice,
58
+ high: newHigh,
59
+ low: newLow,
60
+ volume: newVolume,
61
+ };
62
+
63
+ return [...prev.slice(1), newPoint];
64
+ });
65
+ }, 1200);
66
+
67
+ return () => clearInterval(interval);
68
+ }, [isPlaying]);
69
+
70
+ const currentPoint = hoveredPoint || data[data.length - 1];
71
+ const firstPoint = data[0];
72
+ const priceDiff = currentPoint.price - firstPoint.price;
73
+ const pctDiff = ((priceDiff / firstPoint.price) * 100).toFixed(2);
74
+ const isPositive = priceDiff >= 0;
75
+
76
+ // 차트 좌표 계산
77
+ const prices = data.map((d) => d.price);
78
+ const minPrice = Math.min(...prices) * 0.998;
79
+ const maxPrice = Math.max(...prices) * 1.002;
80
+ const range = maxPrice - minPrice || 1;
81
+
82
+ const svgWidth = 600;
83
+ const svgHeight = 200;
84
+ const paddingX = 20;
85
+ const paddingY = 20;
86
+
87
+ const getX = (idx: number) => paddingX + (idx / (data.length - 1)) * (svgWidth - paddingX * 2);
88
+ const getY = (price: number) => svgHeight - paddingY - ((price - minPrice) / range) * (svgHeight - paddingY * 2);
89
+
90
+ const pointsPath = data.map((d, i) => `${i === 0 ? 'M' : 'L'} ${getX(i)} ${getY(d.price)}`).join(' ');
91
+ const areaPath = `${pointsPath} L ${getX(data.length - 1)} ${svgHeight} L ${getX(0)} ${svgHeight} Z`;
92
+
93
+ return (
94
+ <div className="w-full bg-(--bg-primary) rounded-2xl border border-(--border-subtle) shadow-sm overflow-hidden p-5 sm:p-6 transition-colors">
95
+ {/* Top Header & Actions */}
96
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-(--border-subtle)">
97
+ <div>
98
+ <div className="flex items-center gap-2 mb-1">
99
+ <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
100
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
101
+ LIVE TICK
102
+ </span>
103
+ <span className="text-xs font-medium text-(--text-muted)">KRW-ATOM / 실시간 지수</span>
104
+ </div>
105
+ <div className="flex items-baseline gap-3">
106
+ <h2
107
+ className={`text-2xl sm:text-3xl font-extrabold tracking-tight transition-colors duration-300 ${
108
+ tickFlash === 'up'
109
+ ? 'text-emerald-500'
110
+ : tickFlash === 'down'
111
+ ? 'text-rose-500'
112
+ : 'text-(--text-primary)'
113
+ }`}
114
+ >
115
+ ₩{currentPoint.price.toLocaleString()}
116
+ </h2>
117
+ <div
118
+ className={`inline-flex items-center gap-1 text-xs sm:text-sm font-semibold px-2 py-0.5 rounded-md ${
119
+ isPositive
120
+ ? 'text-emerald-600 bg-emerald-500/10 dark:text-emerald-400'
121
+ : 'text-rose-600 bg-rose-500/10 dark:text-rose-400'
122
+ }`}
123
+ >
124
+ {isPositive ? <TrendingUp className="w-3.5 h-3.5" /> : <TrendingDown className="w-3.5 h-3.5" />}
125
+ <span>{isPositive ? '+' : ''}{priceDiff.toLocaleString()}원 ({isPositive ? '+' : ''}{pctDiff}%)</span>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ {/* Controls Toolbar */}
131
+ <div className="flex flex-wrap items-center gap-2">
132
+ {/* Timeframe Filter */}
133
+ <div className="flex bg-(--bg-secondary) p-0.5 rounded-lg border border-(--border-subtle) text-xs">
134
+ {(['1m', '5m', '1h', '1d'] as const).map((tf) => (
135
+ <button
136
+ key={tf}
137
+ onClick={() => setTimeframe(tf)}
138
+ className={`px-2.5 py-1 rounded-md font-medium transition-colors ${
139
+ timeframe === tf
140
+ ? 'bg-(--bg-primary) text-(--text-primary) shadow-xs font-semibold'
141
+ : 'text-(--text-secondary) hover:text-(--text-primary)'
142
+ }`}
143
+ >
144
+ {tf}
145
+ </button>
146
+ ))}
147
+ </div>
148
+
149
+ {/* Chart Type Toggle */}
150
+ <div className="flex bg-(--bg-secondary) p-0.5 rounded-lg border border-(--border-subtle) text-xs">
151
+ <button
152
+ onClick={() => setChartType('line')}
153
+ data-active={chartType === 'line'}
154
+ className={`px-2.5 py-1 rounded-md font-medium transition-colors ${
155
+ chartType === 'line'
156
+ ? 'bg-(--bg-primary) text-(--text-primary) shadow-xs font-semibold'
157
+ : 'text-(--text-secondary) hover:text-(--text-primary)'
158
+ }`}
159
+ >
160
+ 라인
161
+ </button>
162
+ <button
163
+ onClick={() => setChartType('candle')}
164
+ data-active={chartType === 'candle'}
165
+ className={`px-2.5 py-1 rounded-md font-medium transition-colors ${
166
+ chartType === 'candle'
167
+ ? 'bg-(--bg-primary) text-(--text-primary) shadow-xs font-semibold'
168
+ : 'text-(--text-secondary) hover:text-(--text-primary)'
169
+ }`}
170
+ >
171
+ 캔들
172
+ </button>
173
+ </div>
174
+
175
+ {/* Play/Pause Button */}
176
+ <button
177
+ onClick={() => setIsPlaying(!isPlaying)}
178
+ aria-label={isPlaying ? '일시정지' : '실시간 재생'}
179
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-(--bg-secondary) hover:bg-(--border-subtle) text-(--text-primary) border border-(--border-subtle) transition-colors"
180
+ >
181
+ {isPlaying ? (
182
+ <>
183
+ <Pause className="w-3.5 h-3.5 text-amber-500" />
184
+ <span>일시정지</span>
185
+ </>
186
+ ) : (
187
+ <>
188
+ <Play className="w-3.5 h-3.5 text-emerald-500 fill-emerald-500" />
189
+ <span>재생</span>
190
+ </>
191
+ )}
192
+ </button>
193
+ </div>
194
+ </div>
195
+
196
+ {/* KPI Stats Pill Row */}
197
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 my-4">
198
+ <div className="p-2.5 rounded-xl bg-(--bg-secondary)/60 border border-(--border-subtle)">
199
+ <div className="text-[10px] text-(--text-muted) font-medium">최고가 (High)</div>
200
+ <div className="text-xs sm:text-sm font-bold text-rose-500">₩{Math.max(...data.map(d => d.high)).toLocaleString()}</div>
201
+ </div>
202
+ <div className="p-2.5 rounded-xl bg-(--bg-secondary)/60 border border-(--border-subtle)">
203
+ <div className="text-[10px] text-(--text-muted) font-medium">최저가 (Low)</div>
204
+ <div className="text-xs sm:text-sm font-bold text-blue-500">₩{Math.min(...data.map(d => d.low)).toLocaleString()}</div>
205
+ </div>
206
+ <div className="p-2.5 rounded-xl bg-(--bg-secondary)/60 border border-(--border-subtle)">
207
+ <div className="text-[10px] text-(--text-muted) font-medium">실시간 거래량</div>
208
+ <div className="text-xs sm:text-sm font-bold text-(--text-primary)">{currentPoint.volume.toLocaleString()} ATOM</div>
209
+ </div>
210
+ <div className="p-2.5 rounded-xl bg-(--bg-secondary)/60 border border-(--border-subtle)">
211
+ <div className="text-[10px] text-(--text-muted) font-medium">마지막 체결</div>
212
+ <div className="text-xs sm:text-sm font-bold text-(--text-primary) flex items-center gap-1">
213
+ <Clock className="w-3 h-3 text-(--text-muted)" />
214
+ {currentPoint.time}
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ {/* Main SVG Interactive Chart */}
220
+ <div className="relative w-full h-56 sm:h-64 mt-2 select-none">
221
+ <svg
222
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
223
+ className="w-full h-full overflow-visible"
224
+ preserveAspectRatio="none"
225
+ onMouseLeave={() => setHoveredPoint(null)}
226
+ >
227
+ <defs>
228
+ <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
229
+ <stop offset="0%" stopColor="var(--accent-primary, #3182f6)" stopOpacity="0.35" />
230
+ <stop offset="100%" stopColor="var(--accent-primary, #3182f6)" stopOpacity="0.0" />
231
+ </linearGradient>
232
+ </defs>
233
+
234
+ {/* Grid lines */}
235
+ <line x1="0" y1={svgHeight * 0.25} x2={svgWidth} y2={svgHeight * 0.25} stroke="currentColor" strokeDasharray="3 3" className="text-(--border-subtle)" />
236
+ <line x1="0" y1={svgHeight * 0.5} x2={svgWidth} y2={svgHeight * 0.5} stroke="currentColor" strokeDasharray="3 3" className="text-(--border-subtle)" />
237
+ <line x1="0" y1={svgHeight * 0.75} x2={svgWidth} y2={svgHeight * 0.75} stroke="currentColor" strokeDasharray="3 3" className="text-(--border-subtle)" />
238
+
239
+ {chartType === 'line' ? (
240
+ <>
241
+ {/* Area fill */}
242
+ <path d={areaPath} fill={`url(#${gradientId})`} />
243
+ {/* Stroke line */}
244
+ <path
245
+ d={pointsPath}
246
+ fill="none"
247
+ stroke="var(--accent-primary, #3182f6)"
248
+ strokeWidth="2.5"
249
+ strokeLinecap="round"
250
+ strokeLinejoin="round"
251
+ />
252
+ {/* Data points dots */}
253
+ {data.map((d, i) => (
254
+ <circle
255
+ key={i}
256
+ cx={getX(i)}
257
+ cy={getY(d.price)}
258
+ r={i === data.length - 1 ? 4.5 : 2.5}
259
+ className="fill-(--accent-primary) transition-all duration-300"
260
+ />
261
+ ))}
262
+ </>
263
+ ) : (
264
+ /* Candlestick view */
265
+ data.map((d, i) => {
266
+ const x = getX(i);
267
+ const isUp = d.price >= (data[i - 1]?.price || d.price);
268
+ const candleTop = getY(Math.max(d.price, data[i - 1]?.price || d.price));
269
+ const candleBottom = getY(Math.min(d.price, data[i - 1]?.price || d.price));
270
+ const candleHeight = Math.max(3, candleBottom - candleTop);
271
+ const colorClass = isUp ? 'fill-emerald-500 stroke-emerald-500' : 'fill-rose-500 stroke-rose-500';
272
+
273
+ return (
274
+ <g key={i}>
275
+ {/* High/Low wick */}
276
+ <line
277
+ x1={x}
278
+ y1={getY(d.high)}
279
+ x2={x}
280
+ y2={getY(d.low)}
281
+ strokeWidth="1.5"
282
+ className={colorClass}
283
+ />
284
+ {/* Body */}
285
+ <rect
286
+ x={x - 8}
287
+ y={candleTop}
288
+ width={16}
289
+ height={candleHeight}
290
+ rx={2}
291
+ className={colorClass}
292
+ />
293
+ </g>
294
+ );
295
+ })
296
+ )}
297
+
298
+ {/* Interactive Hover Crosshair */}
299
+ {hoveredPoint && (
300
+ <g>
301
+ <line
302
+ x1={getX(data.indexOf(hoveredPoint))}
303
+ y1={0}
304
+ x2={getX(data.indexOf(hoveredPoint))}
305
+ y2={svgHeight}
306
+ stroke="var(--accent-primary, #3182f6)"
307
+ strokeDasharray="2 2"
308
+ strokeWidth="1.5"
309
+ />
310
+ <circle
311
+ cx={getX(data.indexOf(hoveredPoint))}
312
+ cy={getY(hoveredPoint.price)}
313
+ r={6}
314
+ className="fill-(--accent-primary) stroke-white stroke-2"
315
+ />
316
+ </g>
317
+ )}
318
+
319
+ {/* Hover hit areas */}
320
+ {data.map((d, i) => (
321
+ <rect
322
+ key={i}
323
+ x={getX(i) - 15}
324
+ y={0}
325
+ width={30}
326
+ height={svgHeight}
327
+ fill="transparent"
328
+ className="cursor-crosshair"
329
+ onMouseEnter={() => setHoveredPoint(d)}
330
+ />
331
+ ))}
332
+ </svg>
333
+
334
+ {/* Hover Tooltip Overlay */}
335
+ {hoveredPoint && (
336
+ <div
337
+ className="absolute top-2 pointer-events-none transform -translate-x-1/2 bg-(--bg-primary)/95 backdrop-blur-md px-3 py-1.5 rounded-lg border border-(--border-strong) shadow-lg text-xs"
338
+ style={{
339
+ left: `${(data.indexOf(hoveredPoint) / (data.length - 1)) * 100}%`,
340
+ }}
341
+ >
342
+ <div className="font-semibold text-(--text-primary)">₩{hoveredPoint.price.toLocaleString()}</div>
343
+ <div className="text-[10px] text-(--text-secondary)">{hoveredPoint.time} · 거래량 {hoveredPoint.volume}</div>
344
+ </div>
345
+ )}
346
+ </div>
347
+
348
+ {/* Footer Info */}
349
+ <div className="flex items-center justify-between text-[11px] text-(--text-muted) pt-3 border-t border-(--border-subtle) mt-3">
350
+ <div className="flex items-center gap-1.5">
351
+ <Activity className="w-3.5 h-3.5 text-(--accent-primary)" />
352
+ <span>W3C DTCG 시맨틱 토큰 100% 바인딩</span>
353
+ </div>
354
+ <div className="flex items-center gap-1">
355
+ <RefreshCw className={`w-3 h-3 ${isPlaying ? 'animate-spin' : ''}`} />
356
+ <span>실시간 1.2s 주기 폴링 시뮬레이션</span>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ );
361
+ }