| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- 'use client';
- import { useEffect, useRef, useState, useCallback } from 'react';
- import { useCryptoContext } from '@/contexts/cryptoProvider';
- import useCandles from '@/hooks/useCandles';
- import useDragScroll from '@/hooks/useDragScroll';
- import useTheme from '@/hooks/useTheme';
- 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 { isDark } = useTheme();
- 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 dragScroll = useDragScroll<HTMLDivElement>();
- 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);
- const getChartColors = useCallback((dark: boolean) => ({
- layout: {
- background: { color: dark ? '#171717' : '#ffffff' },
- textColor: dark ? '#e5e5e5' : '#333333',
- fontSize: 12,
- },
- grid: {
- vertLines: { color: dark ? '#2a2a2a' : '#f0f0f0' },
- horzLines: { color: dark ? '#2a2a2a' : '#f0f0f0' },
- },
- crosshair: {
- mode: 0 as const,
- vertLine: { color: '#9B9B9B', width: 1 as const, style: 3 as const, labelBackgroundColor: '#505050' },
- horzLine: { color: '#9B9B9B', width: 1 as const, style: 3 as const, labelBackgroundColor: '#505050' },
- },
- rightPriceScale: {
- borderColor: dark ? '#333' : '#e0e0e0',
- scaleMargins: { top: 0.1, bottom: 0.25 },
- },
- timeScale: {
- borderColor: dark ? '#333' : '#e0e0e0',
- timeVisible: true,
- secondsVisible: false,
- rightOffset: 5,
- },
- }), []);
- // 차트 초기화
- 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,
- ...getChartColors(isDark),
- });
- 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;
- }
- };
- }, [isDark, getChartColors]);
- // 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' ref={dragScroll.ref} onMouseDown={dragScroll.onMouseDown} onMouseMove={dragScroll.onMouseMove} onMouseUp={dragScroll.onMouseUp} onMouseLeave={dragScroll.onMouseLeave}>
- {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>
- );
- }
|