'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(0); // 호가 가격 목록 추적 (추가/제거 감지용) const prevPricesRef = useRef>(new Set()); // LTP 5m ago 가이드선 const [ltp5mAgo, setLtp5mAgo] = useState(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(); 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 (
호가
준비 중...
); } return (
호가 매도 {formatSize(orderbook.totalAskSize)} 매수 {formatSize(orderbook.totalBidSize)}
{/* 매도 호가 — column-reverse로 낮은 가격이 스프레드 근처 */}
{asks.map((entry, i) => { const isFlash = flashPrice && flashPrice.side === 'ASK' && flashPrice.price === entry.price; const isLtp = ltpInAsks && ltp5mAgo === entry.price; return (
{formatSize(entry.size)} {formatPrice(entry.price)} {isLtp &&
LTP 5m ago
}
); })}
{/* 스프레드 — 항상 중앙 고정 */}
{ticker ? formatPrice(ticker.tradePrice) : ''} 스프레드 {formatPrice(spread)} ({spreadPct}%)
{/* 매수 호가 */}
{bids.map((entry, i) => { const isFlash = flashPrice && flashPrice.side === 'BID' && flashPrice.price === entry.price; const isLtp = ltpInBids && ltp5mAgo === entry.price; return (
{formatPrice(entry.price)} {formatSize(entry.size)} {isLtp &&
LTP 5m ago
}
); })}
); }