TradingChart.tsx 9.5 KB

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