TradingChart.tsx 9.2 KB

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