| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- 'use client';
- import { useMemo, useEffect, useRef, useState } from 'react';
- import { useCryptoContext } from '@/contexts/cryptoProvider';
- import useOrderbook from '@/hooks/useOrderbook';
- import useTrades from '@/hooks/useTrades';
- import './orderbook.scss';
- interface OrderbookProps {
- playOrderbookSound: (type: 'add' | 'remove') => void;
- }
- function formatPrice(price: number): string {
- if (price >= 1000) {
- return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
- }
- if (price >= 1) {
- return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
- }
- return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
- }
- function formatSize(size: number): string {
- if (size >= 1) {
- return size.toFixed(4);
- }
- return size.toFixed(6);
- }
- export default function Orderbook({ playOrderbookSound }: OrderbookProps) {
- const { selectedMarket, tickers } = useCryptoContext();
- const orderbook = useOrderbook(selectedMarket);
- const trades = useTrades(selectedMarket);
- // 최근 체결 가격 추적 (플래시 효과용)
- const [flashPrice, setFlashPrice] = useState<{ price: number; side: string } | null>(null);
- const prevTradeRef = useRef<number>(0);
- // 호가 가격 목록 추적 (추가/제거 감지용)
- const prevPricesRef = useRef<Set<number>>(new Set());
- // LTP 5m ago 가이드선
- const [ltp5mAgo, setLtp5mAgo] = useState<number | null>(null);
- const priceHistoryRef = useRef<{ price: number; time: number }[]>([]);
- // 현재 체결가 기록 + 5분 전 가격 계산
- const ticker = tickers.get(selectedMarket);
- useEffect(() => {
- if (!ticker) return;
- const now = Date.now();
- priceHistoryRef.current.push({ price: ticker.tradePrice, time: now });
- // 5분 이상 된 데이터 정리 (6분까지 보관)
- const cutoff = now - 6 * 60 * 1000;
- priceHistoryRef.current = priceHistoryRef.current.filter((p) => p.time > cutoff);
- // 5분 전 가격 찾기
- const fiveMinAgo = now - 5 * 60 * 1000;
- const closest = priceHistoryRef.current.reduce<{ price: number; time: number } | null>((best, p) => {
- if (p.time <= fiveMinAgo) {
- if (!best || Math.abs(p.time - fiveMinAgo) < Math.abs(best.time - fiveMinAgo)) {
- return p;
- }
- }
- return best;
- }, null);
- setLtp5mAgo(closest?.price ?? null);
- }, [ticker, selectedMarket]);
- // 마켓 변경 시 가격 기록 초기화
- useEffect(() => {
- priceHistoryRef.current = [];
- setLtp5mAgo(null);
- }, [selectedMarket]);
- // 체결 플래시 효과
- useEffect(() => {
- if (trades.length === 0) return;
- const latest = trades[0];
- if (latest.sequentialId !== prevTradeRef.current) {
- prevTradeRef.current = latest.sequentialId;
- setFlashPrice({ price: latest.tradePrice, side: latest.askBid });
- const timer = setTimeout(() => setFlashPrice(null), 500);
- return () => clearTimeout(timer);
- }
- }, [trades]);
- const { asks, bids, maxSize, spread, spreadPct } = useMemo(() => {
- if (!orderbook || !orderbook.units.length) {
- return { asks: [], bids: [], maxSize: 0, spread: 0, spreadPct: '' };
- }
- // asks: 오름차순 정렬 (column-reverse로 표시되므로 낮은 가격이 아래/스프레드 근처)
- const askEntries = orderbook.units
- .filter((u) => u.askPrice > 0)
- .map((u) => ({ price: u.askPrice, size: u.askSize }))
- .sort((a, b) => a.price - b.price)
- .slice(0, 15);
- const bidEntries = orderbook.units
- .filter((u) => u.bidPrice > 0)
- .map((u) => ({ price: u.bidPrice, size: u.bidSize }))
- .sort((a, b) => b.price - a.price)
- .slice(0, 15);
- const allSizes = [...askEntries.map((e) => e.size), ...bidEntries.map((e) => e.size)];
- const max = Math.max(...allSizes, 0.0001);
- const sp = askEntries.length > 0 && bidEntries.length > 0
- ? askEntries[0].price - bidEntries[0].price
- : 0;
- const midPrice = bidEntries.length > 0 ? bidEntries[0].price : 1;
- const pct = midPrice > 0 ? ((sp / midPrice) * 100).toFixed(2) : '0.00';
- return {
- asks: askEntries, // reverse 제거 — CSS column-reverse로 처리
- bids: bidEntries,
- maxSize: max,
- spread: sp,
- spreadPct: pct,
- };
- }, [orderbook]);
- // 호가 사운드 — 매 업데이트마다 재생 (bitFlyer 스타일 연속 딸깍)
- useEffect(() => {
- if (asks.length === 0 && bids.length === 0) return;
- const currentPrices = new Set<number>();
- asks.forEach((e) => currentPrices.add(e.price));
- bids.forEach((e) => currentPrices.add(e.price));
- const prev = prevPricesRef.current;
- if (prev.size > 0) {
- let hasRemove = false;
- prev.forEach((p) => {
- if (!currentPrices.has(p)) hasRemove = true;
- });
- if (hasRemove) {
- playOrderbookSound('remove');
- } else {
- playOrderbookSound('add');
- }
- }
- prevPricesRef.current = currentPrices;
- }, [asks, bids, playOrderbookSound]);
- // LTP 가이드선이 asks/bids 범위에 있는지 확인
- const ltpInAsks = ltp5mAgo !== null && asks.length > 0 && ltp5mAgo >= asks[0]?.price;
- const ltpInBids = ltp5mAgo !== null && bids.length > 0 && ltp5mAgo <= bids[0]?.price;
- if (!orderbook) {
- return (
- <div className='orderbook'>
- <div className='orderbook-title'>
- <span>호가</span>
- </div>
- <div className='orderbook-loading'>준비 중...</div>
- </div>
- );
- }
- return (
- <div className='orderbook'>
- <div className='orderbook-title'>
- <span>호가</span>
- <span className='orderbook-total'>
- <span className='total-ask'>매도 {formatSize(orderbook.totalAskSize)}</span>
- <span className='total-bid'>매수 {formatSize(orderbook.totalBidSize)}</span>
- </span>
- </div>
- <div className='orderbook-body'>
- {/* 매도 호가 — column-reverse로 낮은 가격이 스프레드 근처 */}
- <div className='ask-section'>
- {asks.map((entry, i) => {
- const isFlash = flashPrice && flashPrice.side === 'ASK' && flashPrice.price === entry.price;
- const isLtp = ltpInAsks && ltp5mAgo === entry.price;
- return (
- <div key={`ask-${i}`} className={`orderbook-row ask ${isFlash ? 'flash-sell' : ''}`}>
- <div className='bar' style={{ width: `${(entry.size / maxSize) * 100}%` }} />
- <span className='size'>{formatSize(entry.size)}</span>
- <span className='price'>{formatPrice(entry.price)}</span>
- {isLtp && <div className='ltp-guide'>LTP 5m ago</div>}
- </div>
- );
- })}
- </div>
- {/* 스프레드 — 항상 중앙 고정 */}
- <div className='spread-row'>
- <span className='spread-price'>{ticker ? formatPrice(ticker.tradePrice) : ''}</span>
- <span className='spread-info'>스프레드 {formatPrice(spread)} ({spreadPct}%)</span>
- </div>
- {/* 매수 호가 */}
- <div className='bid-section'>
- {bids.map((entry, i) => {
- const isFlash = flashPrice && flashPrice.side === 'BID' && flashPrice.price === entry.price;
- const isLtp = ltpInBids && ltp5mAgo === entry.price;
- return (
- <div key={`bid-${i}`} className={`orderbook-row bid ${isFlash ? 'flash-buy' : ''}`}>
- <div className='bar' style={{ width: `${(entry.size / maxSize) * 100}%` }} />
- <span className='price'>{formatPrice(entry.price)}</span>
- <span className='size'>{formatSize(entry.size)}</span>
- {isLtp && <div className='ltp-guide'>LTP 5m ago</div>}
- </div>
- );
- })}
- </div>
- </div>
- </div>
- );
- }
|