171 lines
5.2 KiB
TypeScript
171 lines
5.2 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts';
|
|
import type { Theme, Timeframe } from '../../types';
|
|
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
|
|
|
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '10m', '15m', '1h', '4h', '1D'];
|
|
|
|
interface Props {
|
|
market: string;
|
|
theme?: Theme;
|
|
wsLabel?: string;
|
|
timeframe?: Timeframe;
|
|
onTimeframeChange?: (tf: Timeframe) => void;
|
|
}
|
|
|
|
function readChartTheme(container: HTMLElement | null) {
|
|
const root = container?.closest('.app') ?? document.documentElement;
|
|
const s = getComputedStyle(root);
|
|
const v = (name: string, fallback: string) => s.getPropertyValue(name).trim() || fallback;
|
|
return {
|
|
bgColor: v('--bg3', '#1f2335'),
|
|
textColor: v('--text', '#c0caf5'),
|
|
gridColor: v('--sep', 'rgba(122, 162, 247, 0.15)'),
|
|
borderColor: v('--border', 'rgba(122, 162, 247, 0.15)'),
|
|
upColor: v('--up', '#ff6b6b'),
|
|
downColor: v('--down', '#4dabf7'),
|
|
};
|
|
}
|
|
|
|
const PaperMiniChart: React.FC<Props> = ({
|
|
market,
|
|
theme = 'dark',
|
|
wsLabel = 'WebSocket <100ms',
|
|
timeframe: timeframeProp,
|
|
onTimeframeChange,
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const chartRef = useRef<IChartApi | null>(null);
|
|
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
|
const [timeframeInternal, setTimeframeInternal] = useState<Timeframe>('1h');
|
|
const timeframe = timeframeProp ?? timeframeInternal;
|
|
const setTimeframe = onTimeframeChange ?? setTimeframeInternal;
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const applyTheme = useCallback((chart: IChartApi, series: ISeriesApi<'Candlestick'>) => {
|
|
const t = readChartTheme(containerRef.current);
|
|
chart.applyOptions({
|
|
layout: {
|
|
background: { type: ColorType.Solid, color: t.bgColor },
|
|
textColor: t.textColor,
|
|
},
|
|
grid: {
|
|
vertLines: { color: t.gridColor },
|
|
horzLines: { color: t.gridColor },
|
|
},
|
|
rightPriceScale: { borderColor: t.borderColor },
|
|
timeScale: { borderColor: t.borderColor },
|
|
});
|
|
series.applyOptions({
|
|
upColor: t.upColor,
|
|
downColor: t.downColor,
|
|
borderUpColor: t.upColor,
|
|
borderDownColor: t.downColor,
|
|
wickUpColor: t.upColor,
|
|
wickDownColor: t.downColor,
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return undefined;
|
|
const t = readChartTheme(containerRef.current);
|
|
const chart = createChart(containerRef.current, {
|
|
layout: {
|
|
background: { type: ColorType.Solid, color: t.bgColor },
|
|
textColor: t.textColor,
|
|
},
|
|
grid: {
|
|
vertLines: { color: t.gridColor },
|
|
horzLines: { color: t.gridColor },
|
|
},
|
|
rightPriceScale: { borderColor: t.borderColor },
|
|
timeScale: { borderColor: t.borderColor },
|
|
crosshair: { mode: 1 },
|
|
});
|
|
const series = chart.addSeries(CandlestickSeries, {
|
|
upColor: t.upColor,
|
|
downColor: t.downColor,
|
|
borderUpColor: t.upColor,
|
|
borderDownColor: t.downColor,
|
|
wickUpColor: t.upColor,
|
|
wickDownColor: t.downColor,
|
|
});
|
|
chartRef.current = chart;
|
|
seriesRef.current = series;
|
|
|
|
const ro = new ResizeObserver(() => {
|
|
if (containerRef.current) {
|
|
chart.applyOptions({
|
|
width: containerRef.current.clientWidth,
|
|
height: containerRef.current.clientHeight,
|
|
});
|
|
}
|
|
});
|
|
ro.observe(containerRef.current);
|
|
|
|
return () => {
|
|
ro.disconnect();
|
|
chart.remove();
|
|
chartRef.current = null;
|
|
seriesRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (chartRef.current && seriesRef.current) {
|
|
applyTheme(chartRef.current, seriesRef.current);
|
|
}
|
|
}, [theme, applyTheme]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
(async () => {
|
|
try {
|
|
const bars = await fetchUpbitCandles(market, timeframe, 200);
|
|
if (cancelled || !seriesRef.current) return;
|
|
seriesRef.current.setData(
|
|
bars.map(b => ({
|
|
time: b.time as Time,
|
|
open: b.open,
|
|
high: b.high,
|
|
low: b.low,
|
|
close: b.close,
|
|
})),
|
|
);
|
|
chartRef.current?.timeScale().fitContent();
|
|
} catch {
|
|
/* ignore */
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [market, timeframe]);
|
|
|
|
return (
|
|
<div className="ptd-chart-wrap">
|
|
<div className="ptd-chart-head">
|
|
<span className="ptd-chart-title">실시간 분석 차트</span>
|
|
<span className="ptd-ws-badge">{wsLabel} 실시간 데이터 반영</span>
|
|
</div>
|
|
<div ref={containerRef} className="ptd-chart-canvas" />
|
|
{loading && <div className="ptd-chart-loading">차트 로딩…</div>}
|
|
<div className="ptd-tf-row">
|
|
{TF_OPTIONS.map(tf => (
|
|
<button
|
|
key={tf}
|
|
type="button"
|
|
className={`ptd-tf-btn${timeframe === tf ? ' ptd-tf-btn--active' : ''}`}
|
|
onClick={() => setTimeframe(tf)}
|
|
>
|
|
{tf}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PaperMiniChart;
|