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)}
{row.changeRate === null ? "-" : fmtPct(row.changeRate)}
{!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 (
);
}