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.
- package/atom.design.md +17 -0
- package/components/blocks/BlockViewerCard.tsx +229 -0
- package/components/blocks/CardListBlock.tsx +309 -0
- package/components/blocks/FileExplorerBlock.tsx +535 -0
- package/components/blocks/InteractiveChatBlock.tsx +233 -0
- package/components/blocks/MetricStatsBlock.tsx +178 -0
- package/components/blocks/PricingPlanBlock.tsx +231 -0
- package/components/blocks/RealtimeChartBlock.tsx +361 -0
- package/components/blocks/SliderShowcaseBlock.tsx +336 -0
- package/components/blocks/index.ts +8 -0
- package/components/ui/Tree.tsx +121 -0
- package/components/ui/index.ts +1 -0
- package/css/atom-ui.css +103 -3
- package/dist/cli.cjs +1 -1
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +90 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +90 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/presets/default.design.md +17 -0
- package/tokens/token.json +1 -1
- package/tokens/tokens.json +1 -1
|
@@ -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
|
+
}
|