스킬 정리 및 리팩토링
This commit is contained in:
473
features/trade/components/orderbook/orderbook-sections.tsx
Normal file
473
features/trade/components/orderbook/orderbook-sections.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
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 (
|
||||
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-blue-50/60 via-amber-50/90 to-red-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-blue-950/30 dark:via-amber-900/30 dark:to-red-950/30 xl:h-10">
|
||||
<div className="px-2 text-right text-[10px] font-semibold text-blue-600 dark:text-blue-400">
|
||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"text-base leading-none font-bold tabular-nums",
|
||||
latestPrice > 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) : "-"}
|
||||
</span>
|
||||
{latestPrice > 0 && basePrice > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold leading-none",
|
||||
latestPrice >= basePrice
|
||||
? "text-red-500"
|
||||
: "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 text-left text-[10px] font-semibold text-red-600 dark:text-red-400">
|
||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 호가 표 헤더 */
|
||||
export function BookHeader() {
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-blue-50/40 via-muted/20 to-red-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-blue-950/30 dark:via-brand-900/40 dark:to-red-950/30 dark:text-brand-100/80">
|
||||
<div className="flex items-center justify-end px-2 text-blue-600/80 dark:text-blue-400/80">
|
||||
매도잔량
|
||||
</div>
|
||||
<div className="flex items-center justify-center border-x border-border/50 dark:border-brand-800/40">
|
||||
호가
|
||||
</div>
|
||||
<div className="flex items-center justify-start px-2 text-red-600/80 dark:text-red-400/80">
|
||||
매수잔량
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 매도 또는 매수 호가 행 목록 */
|
||||
export function BookSideRows({
|
||||
rows,
|
||||
side,
|
||||
maxSize,
|
||||
}: {
|
||||
rows: BookRow[];
|
||||
side: "ask" | "bid";
|
||||
maxSize: number;
|
||||
}) {
|
||||
const isAsk = side === "ask";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isAsk
|
||||
? "bg-linear-to-r from-blue-50/40 via-blue-50/10 to-transparent dark:from-blue-950/35 dark:via-blue-950/10 dark:to-transparent"
|
||||
: "bg-linear-to-r from-transparent via-red-50/10 to-red-50/45 dark:from-transparent dark:via-red-950/10 dark:to-red-950/35",
|
||||
)}
|
||||
>
|
||||
{rows.map((row, i) => {
|
||||
const ratio =
|
||||
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${side}-${row.price}-${i}`}
|
||||
className={cn(
|
||||
"grid h-[29px] grid-cols-3 border-b border-border/30 text-[11px] transition-colors hover:bg-muted/15 xl:h-[29px] xl:text-xs dark:border-brand-800/25 dark:hover:bg-brand-900/20",
|
||||
row.isHighlighted &&
|
||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
||||
{isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="ask" />
|
||||
{row.size > 0 ? (
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
side="ask"
|
||||
className="relative z-10"
|
||||
/>
|
||||
) : (
|
||||
<span className="relative z-10 text-transparent">0</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
||||
row.isHighlighted &&
|
||||
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] xl:text-[13px]",
|
||||
isAsk ? "text-blue-600 dark:text-blue-400" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{row.price > 0 ? fmt(row.price) : "-"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||
getChangeToneClass(row.changeValue),
|
||||
)}
|
||||
>
|
||||
{row.changeValue === null
|
||||
? "-"
|
||||
: fmtSignedChange(row.changeValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||
{!isAsk && (
|
||||
<>
|
||||
<DepthBar ratio={ratio} side="bid" />
|
||||
{row.size > 0 ? (
|
||||
<AnimatedQuantity
|
||||
value={row.size}
|
||||
format={fmt}
|
||||
useColor
|
||||
side="bid"
|
||||
className="relative z-10"
|
||||
/>
|
||||
) : (
|
||||
<span className="relative z-10 text-transparent">0</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 우측 요약 패널 */
|
||||
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 (
|
||||
<div className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
|
||||
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryMetricCell
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
tone={item.tone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 체결 목록 (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 = (
|
||||
<div>
|
||||
{visibleTicks.length === 0 && (
|
||||
<div className="flex min-h-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
{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 (
|
||||
<div
|
||||
key={`${t.tickTime}-${t.price}-${i}`}
|
||||
className="grid h-7 grid-cols-3 border-b border-border/40 px-2 text-[13px] dark:border-brand-800/35"
|
||||
>
|
||||
<div className="flex items-center tabular-nums">
|
||||
{fmtTime(t.tickTime)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end tabular-nums",
|
||||
getChangeToneClass(
|
||||
t.change,
|
||||
"text-foreground dark:text-brand-50",
|
||||
),
|
||||
)}
|
||||
>
|
||||
{fmt(t.price)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end tabular-nums",
|
||||
volumeToneClass,
|
||||
)}
|
||||
>
|
||||
{fmt(t.tradeVolume)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/20">
|
||||
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 px-2 text-xs font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||
<div className="flex items-center">체결시각</div>
|
||||
<div className="flex items-center justify-end">체결가</div>
|
||||
<div className="flex items-center justify-end">체결량</div>
|
||||
</div>
|
||||
{shouldUseScrollableList ? (
|
||||
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
|
||||
) : (
|
||||
tapeRows
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 누적호가 행 */
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
|
||||
>
|
||||
<span className="tabular-nums text-blue-600 dark:text-blue-400">
|
||||
{fmt(r.askAcc)}
|
||||
</span>
|
||||
<span className="text-center font-medium tabular-nums">
|
||||
{fmt(r.price)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-red-600 dark:text-red-400">
|
||||
{fmt(r.bidAcc)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 로딩 스켈레톤 */
|
||||
export function OrderBookSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3">
|
||||
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 16 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-full min-w-0 items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
|
||||
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-semibold tabular-nums",
|
||||
tone === "ask" && "text-blue-600 dark:text-blue-400",
|
||||
tone === "bid" && "text-red-600",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 잔량 깊이 바 */
|
||||
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
if (ratio <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
|
||||
side === "ask"
|
||||
? "right-0.5 bg-blue-300/55 dark:bg-blue-700/50"
|
||||
: "left-0.5 bg-red-300/60 dark:bg-red-600/45",
|
||||
)}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user