'use client'; import { useEffect, useRef, useState, useCallback } from 'react'; import { useCryptoContext } from '@/contexts/cryptoProvider'; import useCandles from '@/hooks/useCandles'; import './trading-chart.scss'; type IntervalType = 'sec' | '1' | '3' | '5' | '10' | '15' | '30' | '60' | '240' | 'day' | 'week' | 'month'; const INTERVALS: { label: string; value: IntervalType }[] = [ { label: '1초', value: 'sec' }, { label: '1분', value: '1' }, { label: '3분', value: '3' }, { label: '5분', value: '5' }, { label: '15분', value: '15' }, { label: '30분', value: '30' }, { label: '1시간', value: '60' }, { label: '4시간', value: '240' }, { label: '1일', value: 'day' }, { label: '1주', value: 'week' }, { label: '1월', value: 'month' }, ]; const MA_COLORS = { ma5: '#E8D44D', ma10: '#E88B4D', ma20: '#4DA6E8', } as const; export default function TradingChart() { const { selectedMarket } = useCryptoContext(); const [interval, setInterval] = useState('15'); const [showMA, setShowMA] = useState({ ma5: true, ma10: true, ma20: true }); const [isFullscreen, setIsFullscreen] = useState(false); const { candles, volumes, ma5, ma10, ma20, loading } = useCandles(selectedMarket, interval); const wrapperRef = useRef(null); const chartContainerRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const chartRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const candleSeriesRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const volumeSeriesRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const ma5SeriesRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const ma10SeriesRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const ma20SeriesRef = useRef(null); const prevDataKeyRef = useRef(''); const prevCandleLenRef = useRef(0); // 차트 초기화 useEffect(() => { if (!chartContainerRef.current) return; let disposed = false; const container = chartContainerRef.current; const initChart = async () => { const { createChart, CandlestickSeries, HistogramSeries, LineSeries } = await import('lightweight-charts'); if (disposed) return; if (chartRef.current) { chartRef.current.remove(); } const chart = createChart(container, { width: container.clientWidth, height: container.clientHeight || 500, layout: { background: { color: '#ffffff' }, textColor: '#333333', fontSize: 12, }, grid: { vertLines: { color: '#f0f0f0' }, horzLines: { color: '#f0f0f0' }, }, crosshair: { mode: 0, vertLine: { color: '#9B9B9B', width: 1, style: 3, labelBackgroundColor: '#505050' }, horzLine: { color: '#9B9B9B', width: 1, style: 3, labelBackgroundColor: '#505050' }, }, rightPriceScale: { borderColor: '#e0e0e0', scaleMargins: { top: 0.1, bottom: 0.25 }, }, timeScale: { borderColor: '#e0e0e0', timeVisible: true, secondsVisible: false, rightOffset: 5, }, }); const candleSeries = chart.addSeries(CandlestickSeries, { upColor: '#ef5350', downColor: '#42a5f5', borderUpColor: '#ef5350', borderDownColor: '#42a5f5', wickUpColor: '#ef5350', wickDownColor: '#42a5f5', }); const volumeSeries = chart.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'volume', }); chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.8, bottom: 0 }, }); const ma5Series = chart.addSeries(LineSeries, { color: MA_COLORS.ma5, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false, }); const ma10Series = chart.addSeries(LineSeries, { color: MA_COLORS.ma10, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false, }); const ma20Series = chart.addSeries(LineSeries, { color: MA_COLORS.ma20, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false, }); chartRef.current = chart; candleSeriesRef.current = candleSeries; volumeSeriesRef.current = volumeSeries; ma5SeriesRef.current = ma5Series; ma10SeriesRef.current = ma10Series; ma20SeriesRef.current = ma20Series; const resizeObserver = new ResizeObserver((entries) => { if (entries[0]) { const { width, height } = entries[0].contentRect; chart.applyOptions({ width, height }); } }); resizeObserver.observe(container); return () => { resizeObserver.disconnect(); }; }; const cleanup = initChart(); return () => { disposed = true; cleanup?.then((fn) => fn?.()); if (chartRef.current) { chartRef.current.remove(); chartRef.current = null; } }; }, []); // secondsVisible 동적 변경 useEffect(() => { if (chartRef.current) { chartRef.current.timeScale().applyOptions({ secondsVisible: interval === 'sec', }); } }, [interval]); // 데이터 업데이트 useEffect(() => { if (!candleSeriesRef.current || !volumeSeriesRef.current || candles.length === 0) return; const dataKey = `${selectedMarket}-${interval}`; const isNewData = dataKey !== prevDataKeyRef.current; const lengthChanged = candles.length !== prevCandleLenRef.current; if (isNewData || lengthChanged) { // 마켓/인터벌 변경 또는 캔들 수 변경 시 전체 데이터 설정 candleSeriesRef.current.setData(candles); volumeSeriesRef.current.setData(volumes); if (isNewData && chartRef.current) { chartRef.current.timeScale().fitContent(); } prevDataKeyRef.current = dataKey; } else { // 마지막 캔들만 업데이트 (같은 시간대 close/high/low 변경) try { const lastCandle = candles[candles.length - 1]; const lastVolume = volumes[volumes.length - 1]; candleSeriesRef.current.update(lastCandle); volumeSeriesRef.current.update(lastVolume); } catch { // 시간 순서 불일치 시 전체 재설정 candleSeriesRef.current.setData(candles); volumeSeriesRef.current.setData(volumes); } } prevCandleLenRef.current = candles.length; }, [candles, volumes, selectedMarket, interval]); // MA 데이터 업데이트 useEffect(() => { if (ma5SeriesRef.current) { ma5SeriesRef.current.setData(showMA.ma5 ? ma5 : []); } if (ma10SeriesRef.current) { ma10SeriesRef.current.setData(showMA.ma10 ? ma10 : []); } if (ma20SeriesRef.current) { ma20SeriesRef.current.setData(showMA.ma20 ? ma20 : []); } }, [ma5, ma10, ma20, showMA]); const toggleMA = useCallback((key: 'ma5' | 'ma10' | 'ma20') => { setShowMA((prev) => ({ ...prev, [key]: !prev[key] })); }, []); // 풀스크린 토글 const toggleFullscreen = useCallback(() => { if (!wrapperRef.current) return; if (!document.fullscreenElement) { wrapperRef.current.requestFullscreen(); } else { document.exitFullscreen(); } }, []); // 풀스크린 상태 동기화 useEffect(() => { const handleChange = () => { setIsFullscreen(!!document.fullscreenElement); }; document.addEventListener('fullscreenchange', handleChange); return () => document.removeEventListener('fullscreenchange', handleChange); }, []); return (
{INTERVALS.map((item) => ( ))}
{loading &&
차트 로딩 중...
}
); }