|
|
@@ -0,0 +1,307 @@
|
|
|
+'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<IntervalType>('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<HTMLDivElement>(null);
|
|
|
+ const chartContainerRef = useRef<HTMLDivElement>(null);
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
+ const chartRef = useRef<any>(null);
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
+ const candleSeriesRef = useRef<any>(null);
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
+ const volumeSeriesRef = useRef<any>(null);
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
+ const ma5SeriesRef = useRef<any>(null);
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
+ const ma10SeriesRef = useRef<any>(null);
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
+ const ma20SeriesRef = useRef<any>(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 (
|
|
|
+ <div className={`trading-chart ${isFullscreen ? 'fullscreen' : ''}`} ref={wrapperRef}>
|
|
|
+ <div className='chart-toolbar'>
|
|
|
+ <div className='interval-buttons'>
|
|
|
+ {INTERVALS.map((item) => (
|
|
|
+ <button
|
|
|
+ key={item.value}
|
|
|
+ type='button'
|
|
|
+ className={`interval-btn ${interval === item.value ? 'active' : ''}`}
|
|
|
+ onClick={() => setInterval(item.value)}
|
|
|
+ >
|
|
|
+ {item.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <div className='chart-toolbar-right'>
|
|
|
+ <div className='ma-buttons'>
|
|
|
+ <button
|
|
|
+ type='button'
|
|
|
+ className={`ma-btn ${showMA.ma5 ? 'active' : ''}`}
|
|
|
+ style={{ '--ma-color': MA_COLORS.ma5 } as React.CSSProperties}
|
|
|
+ onClick={() => toggleMA('ma5')}
|
|
|
+ >
|
|
|
+ MA5
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type='button'
|
|
|
+ className={`ma-btn ${showMA.ma10 ? 'active' : ''}`}
|
|
|
+ style={{ '--ma-color': MA_COLORS.ma10 } as React.CSSProperties}
|
|
|
+ onClick={() => toggleMA('ma10')}
|
|
|
+ >
|
|
|
+ MA10
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type='button'
|
|
|
+ className={`ma-btn ${showMA.ma20 ? 'active' : ''}`}
|
|
|
+ style={{ '--ma-color': MA_COLORS.ma20 } as React.CSSProperties}
|
|
|
+ onClick={() => toggleMA('ma20')}
|
|
|
+ >
|
|
|
+ MA20
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ type='button'
|
|
|
+ className='fullscreen-btn'
|
|
|
+ onClick={toggleFullscreen}
|
|
|
+ title={isFullscreen ? '전체화면 종료' : '전체화면'}
|
|
|
+ >
|
|
|
+ {isFullscreen ? '⤓' : '⤢'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className='chart-container' ref={chartContainerRef}>
|
|
|
+ {loading && <div className='chart-loading'>차트 로딩 중...</div>}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|