MarketHeader.tsx 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. 'use client';
  2. import { useEffect, useRef, useState, useCallback } from 'react';
  3. import { useCryptoContext } from '@/contexts/cryptoProvider';
  4. import './market-header.scss';
  5. function formatPrice(price: number): string {
  6. if (price >= 1000) {
  7. return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
  8. }
  9. if (price >= 1) {
  10. return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
  11. }
  12. return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
  13. }
  14. function formatVolume(volume: number): string {
  15. if (volume >= 1_000_000_000) {
  16. return `${(volume / 1_000_000_000).toFixed(1)}B`;
  17. }
  18. if (volume >= 1_000_000) {
  19. return `${(volume / 1_000_000).toFixed(1)}M`;
  20. }
  21. if (volume >= 1_000) {
  22. return `${(volume / 1_000).toFixed(1)}K`;
  23. }
  24. return volume.toFixed(2);
  25. }
  26. const MAX_SPARKLINE_POINTS = 60;
  27. function VolumeSparkline({ value, market }: { value: number; market: string }) {
  28. const historyRef = useRef<number[]>([]);
  29. const prevMarketRef = useRef(market);
  30. const [open, setOpen] = useState(false);
  31. const wrapperRef = useRef<HTMLDivElement>(null);
  32. // 마켓 변경 시 히스토리 초기화
  33. if (market !== prevMarketRef.current) {
  34. historyRef.current = [];
  35. prevMarketRef.current = market;
  36. }
  37. useEffect(() => {
  38. if (value <= 0) return;
  39. const h = historyRef.current;
  40. h.push(value);
  41. if (h.length > MAX_SPARKLINE_POINTS) {
  42. h.shift();
  43. }
  44. }, [value]);
  45. // 외부 클릭 시 닫기
  46. const handleClickOutside = useCallback((e: MouseEvent) => {
  47. if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
  48. setOpen(false);
  49. }
  50. }, []);
  51. useEffect(() => {
  52. if (open) {
  53. document.addEventListener('mousedown', handleClickOutside);
  54. return () => document.removeEventListener('mousedown', handleClickOutside);
  55. }
  56. }, [open, handleClickOutside]);
  57. const history = historyRef.current;
  58. if (history.length < 2) return null;
  59. const min = Math.min(...history);
  60. const max = Math.max(...history);
  61. const range = max - min || 1;
  62. const w = 80;
  63. const h = 20;
  64. const points = history.map((v, i) => {
  65. const x = (i / (history.length - 1)) * w;
  66. const y = h - ((v - min) / range) * h;
  67. return `${x.toFixed(1)},${y.toFixed(1)}`;
  68. }).join(' ');
  69. const isUp = history[history.length - 1] >= history[history.length - 2];
  70. const color = isUp ? 'hsl(var(--crypto-up))' : 'hsl(var(--crypto-down))';
  71. return (
  72. <div className='volume-sparkline-wrapper' ref={wrapperRef}>
  73. <svg
  74. className='volume-sparkline'
  75. width={w}
  76. height={h}
  77. viewBox={`0 0 ${w} ${h}`}
  78. onClick={() => setOpen((prev) => !prev)}
  79. >
  80. <polyline
  81. points={points}
  82. fill='none'
  83. stroke={color}
  84. strokeWidth='1.5'
  85. strokeLinejoin='round'
  86. strokeLinecap='round'
  87. />
  88. </svg>
  89. {open && (
  90. <div className='volume-popover'>
  91. <span className='volume-popover-label'>거래량(24h)</span>
  92. <span className='volume-popover-value'>{formatVolume(value)} KRW</span>
  93. </div>
  94. )}
  95. </div>
  96. );
  97. }
  98. export default function MarketHeader() {
  99. const { selectedMarket, tickers } = useCryptoContext();
  100. const ticker = tickers.get(selectedMarket);
  101. if (!ticker) {
  102. return (
  103. <div className='market-header'>
  104. <div className='market-header-loading'>준비 중...</div>
  105. </div>
  106. );
  107. }
  108. const changeClass = ticker.change === 'RISE' ? 'up' : ticker.change === 'FALL' ? 'down' : 'neutral';
  109. const changeRate = (ticker.signedChangeRate * 100).toFixed(2);
  110. const changeSign = ticker.signedChangePrice >= 0 ? '+' : '';
  111. return (
  112. <div className='market-header'>
  113. <div className='market-title'>
  114. <span className='symbol'>{ticker.symbol}</span>
  115. <span className='pair'>/{selectedMarket.split('-')[0]}</span>
  116. </div>
  117. <div className={`market-price ${changeClass}`}>
  118. <span className='current-price'>{formatPrice(ticker.tradePrice)}</span>
  119. </div>
  120. <div className={`market-change ${changeClass}`}>
  121. <span>{changeSign}{formatPrice(ticker.signedChangePrice)}</span>
  122. <span>({changeSign}{changeRate}%)</span>
  123. </div>
  124. <div className='market-stats'>
  125. <div className='stat'>
  126. <span className='label'>고가</span>
  127. <span className='value up'>{formatPrice(ticker.highPrice)}</span>
  128. </div>
  129. <div className='stat'>
  130. <span className='label'>저가</span>
  131. <span className='value down'>{formatPrice(ticker.lowPrice)}</span>
  132. </div>
  133. <div className='stat volume-stat'>
  134. <span className='label'>거래량(24h)</span>
  135. <VolumeSparkline value={ticker.accTradePrice24h} market={selectedMarket} />
  136. </div>
  137. </div>
  138. </div>
  139. );
  140. }