/**
* 실시간 호가 — 증권앱형 통합 레이아웃
* 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계
*/
import React, { memo, useState, useEffect } from 'react';
import { formatNowClock, useDisplayTimezone } from '../utils/timezone';
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
import type { RecentTrade } from '../hooks/useUpbitRecentTrades';
import { getKoreanName } from '../utils/marketNameCache';
function fmtPrice(p: number): string {
if (p == null || !isFinite(p)) return '-';
if (p >= 1_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (p >= 1) return p.toFixed(2);
return p.toFixed(6);
}
function fmtSize(s: number): string {
if (s == null || !isFinite(s)) return '-';
if (s >= 1_000_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
if (s >= 1) return s.toFixed(4);
return s.toFixed(6);
}
function fmtTradeVol(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1_000) return Math.round(v).toLocaleString('ko-KR');
return v.toFixed(4);
}
function fmtPct(price: number, prevClose: number): string {
if (!prevClose) return '';
const rate = ((price - prevClose) / prevClose) * 100;
return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`;
}
function fmtTotalSize(s: number): string {
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (s >= 1) return s.toFixed(2);
return s.toFixed(4);
}
function coinCode(market: string): string {
return market.replace(/^KRW-/, '');
}
function pctClass(pct: string): string {
if (pct.startsWith('+')) return 'ob-td-pct--up';
if (pct.startsWith('-')) return 'ob-td-pct--dn';
return '';
}
function useClock(): string {
const tz = useDisplayTimezone();
const [t, setT] = useState(() => formatNowClock(tz, true));
useEffect(() => {
const tick = () => setT(formatNowClock(tz, true));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [tz]);
return t;
}
const COLGROUP = (
);
const AskTableRow = memo(function AskTableRow({
unit, prevClose, onClick,
}: {
unit: OrderbookDisplayUnit;
prevClose: number;
onClick?: (price: number, type: 'ask' | 'bid') => void;
}) {
const pct = fmtPct(unit.price, prevClose);
return (
onClick(unit.price, 'ask') : undefined}>
|
|
{fmtPrice(unit.price)} |
{pct} |
);
});
/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */
const BidTableRow = memo(function BidTableRow({
unit, prevClose, onClick,
}: {
unit: OrderbookDisplayUnit;
prevClose: number;
onClick?: (price: number, type: 'ask' | 'bid') => void;
}) {
const pct = fmtPct(unit.price, prevClose);
return (
onClick(unit.price, 'bid') : undefined}>
| {pct} |
{fmtPrice(unit.price)} |
|
);
});
function QuoteHeader({
market, tickerInfo, prevClose, totalAskSize, totalBidSize, wsStatus,
}: {
market: string;
tickerInfo?: OrderbookTickerInfo;
prevClose: number;
totalAskSize: number;
totalBidSize: number;
wsStatus: WsStatus;
}) {
const price = tickerInfo?.tradePrice ?? 0;
const change = price > 0 && prevClose > 0 ? price - prevClose : (tickerInfo?.changePrice ?? 0);
const rate = tickerInfo?.changeRate != null
? tickerInfo.changeRate * 100
: (prevClose > 0 && price > 0 ? (change / prevClose) * 100 : 0);
const isUp = change >= 0;
const vol = tickerInfo?.accTradeVolume24 ?? 0;
const bidRatio = totalAskSize + totalBidSize > 0
? (totalBidSize / (totalAskSize + totalBidSize)) * 100
: 50;
const code = coinCode(market);
return (
{getKoreanName(market)}
{price > 0 ? fmtPrice(price) : '-'}
{change >= 0 ? '+' : ''}{Math.abs(change) >= 1000
? Math.round(change).toLocaleString('ko-KR')
: change.toFixed(change >= 1 ? 0 : 2)}
{rate >= 0 ? '+' : ''}{rate.toFixed(2)}%
거래량
{vol > 0 ? `${Math.round(vol).toLocaleString('ko-KR')} ${code}` : '-'}
매수비
{bidRatio.toFixed(2)}%
);
}
function WsDot({ status }: { status: WsStatus }) {
const cls = status === 'connected' ? 'ob-dot--ok'
: status === 'connecting' ? 'ob-dot--wait' : 'ob-dot--err';
return ;
}
function StatsRail({ tickerInfo, prevClose }: {
tickerInfo: OrderbookTickerInfo;
prevClose: number;
}) {
return (
);
}
function TradesPanel({ trades, strength }: { trades: RecentTrade[]; strength: number | null }) {
return (
);
}
export interface OrderbookTickerInfo {
tradePrice: number | null;
changeRate: number | null;
changePrice?: number | null;
accTradePrice24: number;
accTradeVolume24?: number | null;
highPrice: number | null;
lowPrice: number | null;
openingPrice: number | null;
}
export interface OrderbookPanelProps {
market: string;
asks: OrderbookDisplayUnit[];
bids: OrderbookDisplayUnit[];
totalAskSize: number;
totalBidSize: number;
wsStatus: WsStatus;
bestAsk: number;
bestBid: number;
spread: number;
spreadPct: number;
prevClose: number;
tickerInfo?: OrderbookTickerInfo;
recentTrades?: RecentTrade[];
tradeStrength?: number | null;
onRowClick?: (price: number, rowType: 'ask' | 'bid') => void;
}
const SUB_TABS = ['호가', '체결', '일별', '차트'] as const;
export const OrderbookPanel = memo(function OrderbookPanel({
market, asks, bids, totalAskSize, totalBidSize, wsStatus,
prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick,
}: OrderbookPanelProps) {
const isEmpty = asks.length === 0 && bids.length === 0;
const midPrice = tickerInfo?.tradePrice ?? 0;
const midPct = midPrice > 0 ? fmtPct(midPrice, prevClose) : '';
const clock = useClock();
const isUp = (tickerInfo?.changeRate ?? 0) >= 0;
return (
{isEmpty ? (
) : (
{/* 매도 구간 + 우측 시세 */}
{COLGROUP}
{asks.map((unit, i) => (
))}
{tickerInfo &&
}
{/* 현재가 (가운데 열 정렬) */}
{COLGROUP}
|
{midPrice > 0 ? fmtPrice(midPrice) : '-'}
{midPct && {midPct}}
|
|
{/* 매수 구간 + 좌측 체결 */}
{COLGROUP}
{bids.map((unit, i) => (
))}
)}
{!isEmpty && (
)}
);
});
export default OrderbookPanel;