import { useMemo } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/trade/types/trade.types"; import { cn } from "@/lib/utils"; import { AnimatedQuantity } from "./AnimatedQuantity"; import type { BookRow } from "./orderbook-utils"; import { fmt, fmtPct, fmtSignedChange, fmtTime, getChangeToneClass, pctChange, resolveTickExecutionSide, } from "./orderbook-utils"; /** * @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다. * @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시 * @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행 */ export function CurrentPriceBar({ latestPrice, basePrice, bestAsk, totalAsk, totalBid, }: { latestPrice: number; basePrice: number; bestAsk: number; totalAsk: number; totalBid: number; }) { return (
{totalAsk > 0 ? fmt(totalAsk) : ""}
0 && basePrice > 0 ? latestPrice >= basePrice ? "text-red-600" : "text-blue-600 dark:text-blue-400" : "text-foreground dark:text-brand-50", )} > {latestPrice > 0 ? fmt(latestPrice) : bestAsk > 0 ? fmt(bestAsk) : "-"} {latestPrice > 0 && basePrice > 0 && ( = basePrice ? "text-red-500" : "text-blue-600 dark:text-blue-400", )} > {fmtPct(pctChange(latestPrice, basePrice))} )}
{totalBid > 0 ? fmt(totalBid) : ""}
); } /** 호가 표 헤더 */ export function BookHeader() { return (
매도잔량
호가
매수잔량
); } /** 매도 또는 매수 호가 행 목록 */ export function BookSideRows({ rows, side, maxSize, }: { rows: BookRow[]; side: "ask" | "bid"; maxSize: number; }) { const isAsk = side === "ask"; return (
{rows.map((row, i) => { const ratio = maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0; return (
{isAsk && ( <> {row.size > 0 ? ( ) : ( 0 )} )}
{row.price > 0 ? fmt(row.price) : "-"} {row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)}
{!isAsk && ( <> {row.size > 0 ? ( ) : ( 0 )} )}
); })}
); } /** 우측 요약 패널 */ export function SummaryPanel({ orderBook, latestTick, spread, imbalance, totalAsk, totalBid, }: { orderBook: DashboardStockOrderBookResponse | null; latestTick: DashboardRealtimeTradeTick | null; spread: number; imbalance: number; totalAsk: number; totalBid: number; }) { const displayTradeVolume = latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0 ? (orderBook?.anticipatedVolume ?? 0) : (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0); const summaryItems: SummaryMetric[] = [ { label: "실시간", value: orderBook || latestTick ? "연결됨" : "끊김", tone: orderBook || latestTick ? "ask" : undefined, }, { label: "거래량", value: fmt(displayTradeVolume) }, { label: "누적거래량", value: fmt( latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0, ), }, { label: "체결강도", value: latestTick ? `${latestTick.tradeStrength.toFixed(2)}%` : orderBook?.anticipatedChangeRate !== undefined ? `${orderBook.anticipatedChangeRate.toFixed(2)}%` : "-", }, { label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) }, { label: "매도1호가", value: latestTick ? fmt(latestTick.askPrice1) : "-", tone: "ask", }, { label: "매수1호가", value: latestTick ? fmt(latestTick.bidPrice1) : "-", tone: "bid", }, { label: "순매수체결", value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-", }, { label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" }, { label: "총 매수잔량", value: fmt(totalBid), tone: "bid" }, { label: "스프레드", value: fmt(spread) }, { label: "수급 불균형", value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`, tone: imbalance >= 0 ? "bid" : "ask", }, ]; return (
{summaryItems.map((item) => ( ))}
); } /** 체결 목록 (Trade Tape) */ export function TradeTape({ ticks, maxRows, }: { ticks: DashboardRealtimeTradeTick[]; maxRows?: number; }) { const visibleTicks = typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks; const shouldUseScrollableList = typeof maxRows !== "number"; const tapeRows = (
{visibleTicks.length === 0 && (
체결 데이터가 아직 없습니다.
)} {visibleTicks.map((t, i) => { const olderTick = visibleTicks[i + 1]; const executionSide = resolveTickExecutionSide(t, olderTick); const volumeToneClass = executionSide === "buy" ? "text-red-600" : executionSide === "sell" ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground dark:text-brand-100/70"; return (
{fmtTime(t.tickTime)}
{fmt(t.price)}
{fmt(t.tradeVolume)}
); })}
); return (
체결시각
체결가
체결량
{shouldUseScrollableList ? ( {tapeRows} ) : ( tapeRows )}
); } /** 누적호가 행 */ export function CumulativeRows({ asks, bids, }: { asks: BookRow[]; bids: BookRow[]; }) { const rows = useMemo(() => { const len = Math.max(asks.length, bids.length); const result: { askAcc: number; bidAcc: number; price: number }[] = []; for (let i = 0; i < len; i += 1) { const prevAsk = result[i - 1]?.askAcc ?? 0; const prevBid = result[i - 1]?.bidAcc ?? 0; result.push({ askAcc: prevAsk + (asks[i]?.size ?? 0), bidAcc: prevBid + (bids[i]?.size ?? 0), price: asks[i]?.price || bids[i]?.price || 0, }); } return result; }, [asks, bids]); return (
{rows.map((r, i) => (
{fmt(r.askAcc)} {fmt(r.price)} {fmt(r.bidAcc)}
))}
); } /** 로딩 스켈레톤 */ export function OrderBookSkeleton() { return (
{Array.from({ length: 16 }).map((_, i) => ( ))}
); } interface SummaryMetric { label: string; value: string; tone?: "ask" | "bid"; } /** * @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다. * @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시 * @see features/trade/components/orderbook/orderbook-sections.tsx SummaryPanel summaryItems */ function SummaryMetricCell({ label, value, tone, }: { label: string; value: string; tone?: "ask" | "bid"; }) { return (
{label} {value}
); } /** 잔량 깊이 바 */ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) { if (ratio <= 0) return null; return (
); }