atomsystem 0.4.3 → 0.5.1

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.
@@ -1,7 +1,14 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect, useId } from 'react';
4
- import { Play, Pause, TrendingUp, TrendingDown, Clock, Activity, RefreshCw } from 'lucide-react';
3
+ import React, { useState, useEffect } from 'react';
4
+ import { Card, Button, Badge } from '@/components/ui';
5
+ import {
6
+ Play,
7
+ Pause,
8
+ TrendingUp,
9
+ TrendingDown,
10
+ Activity,
11
+ } from 'lucide-react';
5
12
 
6
13
  interface ChartPoint {
7
14
  time: string;
@@ -30,11 +37,7 @@ export function RealtimeChartBlock() {
30
37
  const [chartType, setChartType] = useState<'line' | 'candle'>('line');
31
38
  const [timeframe, setTimeframe] = useState<'1m' | '5m' | '1h' | '1d'>('1m');
32
39
  const [hoveredPoint, setHoveredPoint] = useState<ChartPoint | null>(null);
33
- const [tickFlash, setTickFlash] = useState<'up' | 'down' | null>(null);
34
40
 
35
- const gradientId = useId();
36
-
37
- // 실시간 틱 데이터 시뮬레이션
38
41
  useEffect(() => {
39
42
  if (!isPlaying) return;
40
43
  const interval = setInterval(() => {
@@ -46,21 +49,13 @@ export function RealtimeChartBlock() {
46
49
  const newLow = Math.min(newPrice, last.low);
47
50
  const newVolume = Math.round(2000 + Math.random() * 4000);
48
51
 
49
- setTickFlash(delta >= 0 ? 'up' : 'down');
50
- setTimeout(() => setTickFlash(null), 400);
51
-
52
52
  const now = new Date();
53
53
  const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
54
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];
55
+ return [
56
+ ...prev.slice(1),
57
+ { time: timeStr, price: newPrice, high: newHigh, low: newLow, volume: newVolume },
58
+ ];
64
59
  });
65
60
  }, 1200);
66
61
 
@@ -73,14 +68,13 @@ export function RealtimeChartBlock() {
73
68
  const pctDiff = ((priceDiff / firstPoint.price) * 100).toFixed(2);
74
69
  const isPositive = priceDiff >= 0;
75
70
 
76
- // 차트 좌표 계산
77
71
  const prices = data.map((d) => d.price);
78
72
  const minPrice = Math.min(...prices) * 0.998;
79
73
  const maxPrice = Math.max(...prices) * 1.002;
80
74
  const range = maxPrice - minPrice || 1;
81
75
 
82
76
  const svgWidth = 600;
83
- const svgHeight = 200;
77
+ const svgHeight = 220;
84
78
  const paddingX = 20;
85
79
  const paddingY = 20;
86
80
 
@@ -88,274 +82,161 @@ export function RealtimeChartBlock() {
88
82
  const getY = (price: number) => svgHeight - paddingY - ((price - minPrice) / range) * (svgHeight - paddingY * 2);
89
83
 
90
84
  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
85
 
93
86
  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)">
87
+ <Card className="overflow-hidden bg-(--bg-primary) border border-(--border-subtle) transition-colors">
88
+ {/* 1. Header: Minimalist Price & Controls */}
89
+ <div className="p-4 sm:p-5 border-b border-(--border-subtle) flex flex-col sm:flex-row sm:items-center justify-between gap-3">
97
90
  <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
91
+ <div className="flex items-center gap-2 mb-0.5">
92
+ <span className="font-mono text-xs font-semibold text-(--text-secondary)">KRW-ATOM</span>
93
+ <span className={`text-xs font-mono font-medium ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'}`}>
94
+ {isPositive ? '+' : ''}{pctDiff}%
102
95
  </span>
103
- <span className="text-xs font-medium text-(--text-muted)">KRW-ATOM / 실시간 지수</span>
104
96
  </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>
97
+ <div className="text-xl font-bold tracking-tight text-(--text-primary)">
98
+ ₩{currentPoint.price.toLocaleString()}
127
99
  </div>
128
100
  </div>
129
101
 
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">
102
+ <div className="flex items-center gap-2">
103
+ {/* Timeframe Button Group */}
104
+ <div className="flex rounded-(--radius-sm) border border-(--border-subtle) p-0.5 text-xs bg-(--bg-secondary)">
134
105
  {(['1m', '5m', '1h', '1d'] as const).map((tf) => (
135
- <button
106
+ <Button
136
107
  key={tf}
108
+ variant={timeframe === tf ? 'primary' : 'ghost'}
109
+ size="sm"
137
110
  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
- }`}
111
+ className="px-2 py-0.5 text-xs h-auto"
143
112
  >
144
113
  {tf}
145
- </button>
114
+ </Button>
146
115
  ))}
147
116
  </div>
148
117
 
149
- {/* Chart Type Toggle */}
150
- <div className="flex bg-(--bg-secondary) p-0.5 rounded-lg border border-(--border-subtle) text-xs">
151
- <button
118
+ {/* Type Button Group */}
119
+ <div className="flex rounded-(--radius-sm) border border-(--border-subtle) p-0.5 text-xs bg-(--bg-secondary)">
120
+ <Button
121
+ variant={chartType === 'line' ? 'primary' : 'ghost'}
122
+ size="sm"
152
123
  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
- }`}
124
+ className="px-2 py-0.5 text-xs h-auto"
159
125
  >
160
- 라인
161
- </button>
162
- <button
126
+ Line
127
+ </Button>
128
+ <Button
129
+ variant={chartType === 'candle' ? 'primary' : 'ghost'}
130
+ size="sm"
163
131
  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
- }`}
132
+ className="px-2 py-0.5 text-xs h-auto"
170
133
  >
171
- 캔들
172
- </button>
134
+ Candle
135
+ </Button>
173
136
  </div>
174
137
 
175
138
  {/* Play/Pause Button */}
176
- <button
139
+ <Button
140
+ variant="outline"
141
+ size="sm"
177
142
  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"
143
+ aria-label={isPlaying ? 'Pause stream' : 'Resume stream'}
144
+ title={isPlaying ? 'Pause stream' : 'Resume stream'}
145
+ className="px-2.5 py-1 text-xs h-auto flex items-center gap-1.5"
180
146
  >
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>
147
+ {isPlaying ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
148
+ <span className="text-xs font-medium">{isPlaying ? '일시정지' : '재생'}</span>
149
+ </Button>
216
150
  </div>
217
151
  </div>
218
152
 
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>
153
+ {/* 2. Pure SVG Canvas (Zero-dependency, Token-based) */}
154
+ <div className="p-4 sm:p-5">
155
+ <div className="relative w-full h-56">
156
+ <svg
157
+ className="w-full h-full overflow-visible"
158
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
159
+ preserveAspectRatio="none"
160
+ >
161
+ <defs>
162
+ <linearGradient id="chartGradientToken" x1="0" y1="0" x2="0" y2="1">
163
+ <stop offset="0%" stopColor="var(--accent-primary)" stopOpacity="0.22" />
164
+ <stop offset="100%" stopColor="var(--accent-primary)" stopOpacity="0.0" />
165
+ </linearGradient>
166
+ </defs>
167
+
168
+ {/* Subtle Grid Lines */}
169
+ <line x1={paddingX} y1={paddingY} x2={svgWidth - paddingX} y2={paddingY} stroke="var(--border-subtle)" strokeDasharray="4 4" />
170
+ <line x1={paddingX} y1={svgHeight / 2} x2={svgWidth - paddingX} y2={svgHeight / 2} stroke="var(--border-subtle)" strokeDasharray="4 4" />
171
+ <line x1={paddingX} y1={svgHeight - paddingY} x2={svgWidth - paddingX} y2={svgHeight - paddingY} stroke="var(--border-subtle)" strokeDasharray="4 4" />
172
+
173
+ {/* Area */}
174
+ <path
175
+ d={`${pointsPath} L ${getX(data.length - 1)} ${svgHeight - paddingY} L ${getX(0)} ${svgHeight - paddingY} Z`}
176
+ fill="url(#chartGradientToken)"
177
+ />
233
178
 
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)" />
179
+ {/* Path */}
180
+ <path
181
+ d={pointsPath}
182
+ fill="none"
183
+ stroke="var(--accent-primary)"
184
+ strokeWidth="2"
185
+ strokeLinecap="round"
186
+ strokeLinejoin="round"
187
+ />
238
188
 
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"
189
+ {/* Points */}
190
+ {data.map((d, i) => (
191
+ <circle
192
+ key={i}
193
+ cx={getX(i)}
194
+ cy={getY(d.price)}
195
+ r={hoveredPoint === d ? 5 : 3}
196
+ fill="var(--bg-primary)"
197
+ stroke="var(--accent-primary)"
198
+ strokeWidth="2"
199
+ className="cursor-pointer transition-all"
200
+ onMouseEnter={() => setHoveredPoint(d)}
251
201
  />
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
- )}
202
+ ))}
203
+ </svg>
297
204
 
298
- {/* Interactive Hover Crosshair */}
299
205
  {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>
206
+ <div
207
+ className="absolute top-2 pointer-events-none transform -translate-x-1/2 bg-(--bg-primary) px-2.5 py-1 rounded-(--radius-sm) border border-(--border-strong) shadow-md text-xs"
208
+ style={{
209
+ left: `${(data.indexOf(hoveredPoint) / (data.length - 1)) * 100}%`,
210
+ }}
211
+ >
212
+ <div className="font-semibold text-(--text-primary)">₩{hoveredPoint.price.toLocaleString()}</div>
213
+ <div className="text-[11px] text-(--text-secondary)">{hoveredPoint.time} · Vol: {hoveredPoint.volume}</div>
214
+ </div>
317
215
  )}
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
- )}
216
+ </div>
346
217
  </div>
347
218
 
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>
219
+ {/* 3. Metrics Summary Strip: Clean Monochrome */}
220
+ <div className="grid grid-cols-2 sm:grid-cols-4 border-t border-(--border-subtle) divide-x divide-(--border-subtle) text-xs">
221
+ <div className="p-3 px-4">
222
+ <div className="text-[11px] text-(--text-muted)">24h High</div>
223
+ <div className="font-mono text-xs text-(--text-primary) mt-0.5">₩{maxPrice.toFixed(0)}</div>
224
+ </div>
225
+ <div className="p-3 px-4">
226
+ <div className="text-[11px] text-(--text-muted)">24h Low</div>
227
+ <div className="font-mono text-xs text-(--text-primary) mt-0.5">₩{minPrice.toFixed(0)}</div>
353
228
  </div>
354
- <div className="flex items-center gap-1">
355
- <RefreshCw className={`w-3 h-3 ${isPlaying ? 'animate-spin' : ''}`} />
356
- <span>실시간 1.2s 주기 폴링 시뮬레이션</span>
229
+ <div className="p-3 px-4">
230
+ <div className="text-[11px] text-(--text-muted)">24h Volume</div>
231
+ <div className="font-mono text-xs text-(--text-primary) mt-0.5">38,420 ATOM</div>
232
+ </div>
233
+ <div className="p-3 px-4">
234
+ <div className="text-[11px] text-(--text-muted)">Change</div>
235
+ <div className={`font-mono text-xs mt-0.5 ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'}`}>
236
+ {isPositive ? '+' : ''}{priceDiff.toLocaleString()} 원
237
+ </div>
357
238
  </div>
358
239
  </div>
359
- </div>
240
+ </Card>
360
241
  );
361
242
  }