TradingChart.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. 'use client';
  2. import { useEffect, useRef, useState, useCallback } from 'react';
  3. import { useCryptoContext } from '@/contexts/cryptoProvider';
  4. import useCandles from '@/hooks/useCandles';
  5. import './trading-chart.scss';
  6. type IntervalType = 'sec' | '1' | '3' | '5' | '10' | '15' | '30' | '60' | '240' | 'day' | 'week' | 'month';
  7. const INTERVALS: { label: string; value: IntervalType }[] = [
  8. { label: '1초', value: 'sec' },
  9. { label: '1분', value: '1' },
  10. { label: '3분', value: '3' },
  11. { label: '5분', value: '5' },
  12. { label: '15분', value: '15' },
  13. { label: '30분', value: '30' },
  14. { label: '1시간', value: '60' },
  15. { label: '4시간', value: '240' },
  16. { label: '1일', value: 'day' },
  17. { label: '1주', value: 'week' },
  18. { label: '1월', value: 'month' },
  19. ];
  20. const MA_COLORS = {
  21. ma5: '#E8D44D',
  22. ma10: '#E88B4D',
  23. ma20: '#4DA6E8',
  24. } as const;
  25. export default function TradingChart() {
  26. const { selectedMarket } = useCryptoContext();
  27. const [interval, setInterval] = useState<IntervalType>('15');
  28. const [showMA, setShowMA] = useState({ ma5: true, ma10: true, ma20: true });
  29. const [isFullscreen, setIsFullscreen] = useState(false);
  30. const { candles, volumes, ma5, ma10, ma20, loading } = useCandles(selectedMarket, interval);
  31. const wrapperRef = useRef<HTMLDivElement>(null);
  32. const chartContainerRef = useRef<HTMLDivElement>(null);
  33. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  34. const chartRef = useRef<any>(null);
  35. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  36. const candleSeriesRef = useRef<any>(null);
  37. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  38. const volumeSeriesRef = useRef<any>(null);
  39. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  40. const ma5SeriesRef = useRef<any>(null);
  41. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  42. const ma10SeriesRef = useRef<any>(null);
  43. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  44. const ma20SeriesRef = useRef<any>(null);
  45. const prevDataKeyRef = useRef('');
  46. const prevCandleLenRef = useRef(0);
  47. // 차트 초기화
  48. useEffect(() => {
  49. if (!chartContainerRef.current) return;
  50. let disposed = false;
  51. const container = chartContainerRef.current;
  52. const initChart = async () => {
  53. const { createChart, CandlestickSeries, HistogramSeries, LineSeries } = await import('lightweight-charts');
  54. if (disposed) return;
  55. if (chartRef.current) {
  56. chartRef.current.remove();
  57. }
  58. const chart = createChart(container, {
  59. width: container.clientWidth,
  60. height: container.clientHeight || 500,
  61. layout: {
  62. background: { color: '#ffffff' },
  63. textColor: '#333333',
  64. fontSize: 12,
  65. },
  66. grid: {
  67. vertLines: { color: '#f0f0f0' },
  68. horzLines: { color: '#f0f0f0' },
  69. },
  70. crosshair: {
  71. mode: 0,
  72. vertLine: { color: '#9B9B9B', width: 1, style: 3, labelBackgroundColor: '#505050' },
  73. horzLine: { color: '#9B9B9B', width: 1, style: 3, labelBackgroundColor: '#505050' },
  74. },
  75. rightPriceScale: {
  76. borderColor: '#e0e0e0',
  77. scaleMargins: { top: 0.1, bottom: 0.25 },
  78. },
  79. timeScale: {
  80. borderColor: '#e0e0e0',
  81. timeVisible: true,
  82. secondsVisible: false,
  83. rightOffset: 5,
  84. },
  85. });
  86. const candleSeries = chart.addSeries(CandlestickSeries, {
  87. upColor: '#ef5350',
  88. downColor: '#42a5f5',
  89. borderUpColor: '#ef5350',
  90. borderDownColor: '#42a5f5',
  91. wickUpColor: '#ef5350',
  92. wickDownColor: '#42a5f5',
  93. });
  94. const volumeSeries = chart.addSeries(HistogramSeries, {
  95. priceFormat: { type: 'volume' },
  96. priceScaleId: 'volume',
  97. });
  98. chart.priceScale('volume').applyOptions({
  99. scaleMargins: { top: 0.8, bottom: 0 },
  100. });
  101. const ma5Series = chart.addSeries(LineSeries, {
  102. color: MA_COLORS.ma5,
  103. lineWidth: 1,
  104. priceLineVisible: false,
  105. lastValueVisible: false,
  106. crosshairMarkerVisible: false,
  107. });
  108. const ma10Series = chart.addSeries(LineSeries, {
  109. color: MA_COLORS.ma10,
  110. lineWidth: 1,
  111. priceLineVisible: false,
  112. lastValueVisible: false,
  113. crosshairMarkerVisible: false,
  114. });
  115. const ma20Series = chart.addSeries(LineSeries, {
  116. color: MA_COLORS.ma20,
  117. lineWidth: 1,
  118. priceLineVisible: false,
  119. lastValueVisible: false,
  120. crosshairMarkerVisible: false,
  121. });
  122. chartRef.current = chart;
  123. candleSeriesRef.current = candleSeries;
  124. volumeSeriesRef.current = volumeSeries;
  125. ma5SeriesRef.current = ma5Series;
  126. ma10SeriesRef.current = ma10Series;
  127. ma20SeriesRef.current = ma20Series;
  128. const resizeObserver = new ResizeObserver((entries) => {
  129. if (entries[0]) {
  130. const { width, height } = entries[0].contentRect;
  131. chart.applyOptions({ width, height });
  132. }
  133. });
  134. resizeObserver.observe(container);
  135. return () => {
  136. resizeObserver.disconnect();
  137. };
  138. };
  139. const cleanup = initChart();
  140. return () => {
  141. disposed = true;
  142. cleanup?.then((fn) => fn?.());
  143. if (chartRef.current) {
  144. chartRef.current.remove();
  145. chartRef.current = null;
  146. }
  147. };
  148. }, []);
  149. // secondsVisible 동적 변경
  150. useEffect(() => {
  151. if (chartRef.current) {
  152. chartRef.current.timeScale().applyOptions({
  153. secondsVisible: interval === 'sec',
  154. });
  155. }
  156. }, [interval]);
  157. // 데이터 업데이트
  158. useEffect(() => {
  159. if (!candleSeriesRef.current || !volumeSeriesRef.current || candles.length === 0) return;
  160. const dataKey = `${selectedMarket}-${interval}`;
  161. const isNewData = dataKey !== prevDataKeyRef.current;
  162. const lengthChanged = candles.length !== prevCandleLenRef.current;
  163. if (isNewData || lengthChanged) {
  164. // 마켓/인터벌 변경 또는 캔들 수 변경 시 전체 데이터 설정
  165. candleSeriesRef.current.setData(candles);
  166. volumeSeriesRef.current.setData(volumes);
  167. if (isNewData && chartRef.current) {
  168. chartRef.current.timeScale().fitContent();
  169. }
  170. prevDataKeyRef.current = dataKey;
  171. } else {
  172. // 마지막 캔들만 업데이트 (같은 시간대 close/high/low 변경)
  173. try {
  174. const lastCandle = candles[candles.length - 1];
  175. const lastVolume = volumes[volumes.length - 1];
  176. candleSeriesRef.current.update(lastCandle);
  177. volumeSeriesRef.current.update(lastVolume);
  178. } catch {
  179. // 시간 순서 불일치 시 전체 재설정
  180. candleSeriesRef.current.setData(candles);
  181. volumeSeriesRef.current.setData(volumes);
  182. }
  183. }
  184. prevCandleLenRef.current = candles.length;
  185. }, [candles, volumes, selectedMarket, interval]);
  186. // MA 데이터 업데이트
  187. useEffect(() => {
  188. if (ma5SeriesRef.current) {
  189. ma5SeriesRef.current.setData(showMA.ma5 ? ma5 : []);
  190. }
  191. if (ma10SeriesRef.current) {
  192. ma10SeriesRef.current.setData(showMA.ma10 ? ma10 : []);
  193. }
  194. if (ma20SeriesRef.current) {
  195. ma20SeriesRef.current.setData(showMA.ma20 ? ma20 : []);
  196. }
  197. }, [ma5, ma10, ma20, showMA]);
  198. const toggleMA = useCallback((key: 'ma5' | 'ma10' | 'ma20') => {
  199. setShowMA((prev) => ({ ...prev, [key]: !prev[key] }));
  200. }, []);
  201. // 풀스크린 토글
  202. const toggleFullscreen = useCallback(() => {
  203. if (!wrapperRef.current) return;
  204. if (!document.fullscreenElement) {
  205. wrapperRef.current.requestFullscreen();
  206. } else {
  207. document.exitFullscreen();
  208. }
  209. }, []);
  210. // 풀스크린 상태 동기화
  211. useEffect(() => {
  212. const handleChange = () => {
  213. setIsFullscreen(!!document.fullscreenElement);
  214. };
  215. document.addEventListener('fullscreenchange', handleChange);
  216. return () => document.removeEventListener('fullscreenchange', handleChange);
  217. }, []);
  218. return (
  219. <div className={`trading-chart ${isFullscreen ? 'fullscreen' : ''}`} ref={wrapperRef}>
  220. <div className='chart-toolbar'>
  221. <div className='interval-buttons'>
  222. {INTERVALS.map((item) => (
  223. <button
  224. key={item.value}
  225. type='button'
  226. className={`interval-btn ${interval === item.value ? 'active' : ''}`}
  227. onClick={() => setInterval(item.value)}
  228. >
  229. {item.label}
  230. </button>
  231. ))}
  232. </div>
  233. <div className='chart-toolbar-right'>
  234. <div className='ma-buttons'>
  235. <button
  236. type='button'
  237. className={`ma-btn ${showMA.ma5 ? 'active' : ''}`}
  238. style={{ '--ma-color': MA_COLORS.ma5 } as React.CSSProperties}
  239. onClick={() => toggleMA('ma5')}
  240. >
  241. MA5
  242. </button>
  243. <button
  244. type='button'
  245. className={`ma-btn ${showMA.ma10 ? 'active' : ''}`}
  246. style={{ '--ma-color': MA_COLORS.ma10 } as React.CSSProperties}
  247. onClick={() => toggleMA('ma10')}
  248. >
  249. MA10
  250. </button>
  251. <button
  252. type='button'
  253. className={`ma-btn ${showMA.ma20 ? 'active' : ''}`}
  254. style={{ '--ma-color': MA_COLORS.ma20 } as React.CSSProperties}
  255. onClick={() => toggleMA('ma20')}
  256. >
  257. MA20
  258. </button>
  259. </div>
  260. <button
  261. type='button'
  262. className='fullscreen-btn'
  263. onClick={toggleFullscreen}
  264. title={isFullscreen ? '전체화면 종료' : '전체화면'}
  265. >
  266. {isFullscreen ? '⤓' : '⤢'}
  267. </button>
  268. </div>
  269. </div>
  270. <div className='chart-container' ref={chartContainerRef}>
  271. {loading && <div className='chart-loading'>차트 로딩 중...</div>}
  272. </div>
  273. </div>
  274. );
  275. }