전략편집기 복합지표 기능 반영
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* 실시간 호가 — 증권앱형 통합 레이아웃
|
||||
* 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계
|
||||
*/
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import React, { memo, useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { formatNowClock, useDisplayTimezone } from '../utils/timezone';
|
||||
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
|
||||
import type { RecentTrade } from '../hooks/useUpbitRecentTrades';
|
||||
@@ -35,16 +35,139 @@ function fmtPct(price: number, prevClose: number): string {
|
||||
return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function fmtTotalAmount(a: number): string {
|
||||
if (a == null || !isFinite(a)) return '-';
|
||||
return a.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/** 하단 합계·호가 잔량 표시 모드 — 기본 총액(KRW) */
|
||||
type SummaryDisplayMode = 'amount' | 'quantity';
|
||||
|
||||
function unitDisplayValue(unit: OrderbookDisplayUnit, mode: SummaryDisplayMode): number {
|
||||
return mode === 'amount' ? unit.price * unit.size : unit.size;
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: number, mode: SummaryDisplayMode): string {
|
||||
return mode === 'amount' ? fmtTotalAmount(value) : fmtSize(value);
|
||||
}
|
||||
|
||||
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 SummaryBar({
|
||||
asks,
|
||||
bids,
|
||||
totalAskSize,
|
||||
totalBidSize,
|
||||
clock,
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
asks: OrderbookDisplayUnit[];
|
||||
bids: OrderbookDisplayUnit[];
|
||||
totalAskSize: number;
|
||||
totalBidSize: number;
|
||||
clock: string;
|
||||
mode: SummaryDisplayMode;
|
||||
onModeChange: (mode: SummaryDisplayMode) => void;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const totalAskAmount = useMemo(
|
||||
() => asks.reduce((sum, u) => sum + u.price * u.size, 0),
|
||||
[asks],
|
||||
);
|
||||
const totalBidAmount = useMemo(
|
||||
() => bids.reduce((sum, u) => sum + u.price * u.size, 0),
|
||||
[bids],
|
||||
);
|
||||
|
||||
const askDisplay = mode === 'amount'
|
||||
? fmtTotalAmount(totalAskAmount)
|
||||
: fmtTotalSize(totalAskSize);
|
||||
const bidDisplay = mode === 'amount'
|
||||
? fmtTotalAmount(totalBidAmount)
|
||||
: fmtTotalSize(totalBidSize);
|
||||
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [menuOpen, closeMenu]);
|
||||
|
||||
const pickMode = (next: SummaryDisplayMode) => {
|
||||
onModeChange(next);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="ob-summary-bar">
|
||||
<div className="ob-summary ob-summary--ask">
|
||||
<span className="ob-summary-num">{askDisplay}</span>
|
||||
</div>
|
||||
<div className="ob-summary ob-summary--time ob-summary-time-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="ob-summary-time-btn"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen(o => !o)}
|
||||
>
|
||||
<span>{clock}</span>
|
||||
<span className="ob-summary-time-caret" aria-hidden>▾</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="ob-summary-mode-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={mode === 'quantity'}
|
||||
className={`ob-summary-mode-item${mode === 'quantity' ? ' ob-summary-mode-item--active' : ''}`}
|
||||
onClick={() => pickMode('quantity')}
|
||||
>
|
||||
수량
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={mode === 'amount'}
|
||||
className={`ob-summary-mode-item${mode === 'amount' ? ' ob-summary-mode-item--active' : ''}`}
|
||||
onClick={() => pickMode('amount')}
|
||||
>
|
||||
총액
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ob-summary ob-summary--bid">
|
||||
<span className="ob-summary-num">{bidDisplay}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function coinCode(market: string): string {
|
||||
return market.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
/** 호가 가격과 현재가(체결가) 일치 여부 */
|
||||
function isLivePrice(levelPrice: number, currentPrice: number): boolean {
|
||||
if (levelPrice <= 0 || currentPrice <= 0) return false;
|
||||
const tol = Math.max(1e-8, Math.abs(currentPrice) * 1e-6);
|
||||
return Math.abs(levelPrice - currentPrice) <= tol;
|
||||
}
|
||||
|
||||
function pctClass(pct: string): string {
|
||||
if (pct.startsWith('+')) return 'ob-td-pct--up';
|
||||
if (pct.startsWith('-')) return 'ob-td-pct--dn';
|
||||
@@ -72,22 +195,28 @@ const COLGROUP = (
|
||||
);
|
||||
|
||||
const AskTableRow = memo(function AskTableRow({
|
||||
unit, prevClose, onClick,
|
||||
unit, prevClose, displayMode, maxDisplay, currentPrice, onClick,
|
||||
}: {
|
||||
unit: OrderbookDisplayUnit;
|
||||
prevClose: number;
|
||||
displayMode: SummaryDisplayMode;
|
||||
maxDisplay: number;
|
||||
currentPrice: number;
|
||||
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
||||
}) {
|
||||
const pct = fmtPct(unit.price, prevClose);
|
||||
const displayVal = unitDisplayValue(unit, displayMode);
|
||||
const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0;
|
||||
const isLive = isLivePrice(unit.price, currentPrice);
|
||||
return (
|
||||
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'ask') : undefined}>
|
||||
<td className="ob-td ob-td-qty ob-td-qty--ask">
|
||||
<div className="ob-td-bar-wrap">
|
||||
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
|
||||
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(barPct, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{formatDisplayValue(displayVal, displayMode)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
|
||||
<td className={`ob-td ob-td-price${isLive ? ' ob-td-price--live' : ''}`}>{fmtPrice(unit.price)}</td>
|
||||
<td className={`ob-td ob-td-pct ${pctClass(pct)}`}>{pct}</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -95,21 +224,27 @@ const AskTableRow = memo(function AskTableRow({
|
||||
|
||||
/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */
|
||||
const BidTableRow = memo(function BidTableRow({
|
||||
unit, prevClose, onClick,
|
||||
unit, prevClose, displayMode, maxDisplay, currentPrice, onClick,
|
||||
}: {
|
||||
unit: OrderbookDisplayUnit;
|
||||
prevClose: number;
|
||||
displayMode: SummaryDisplayMode;
|
||||
maxDisplay: number;
|
||||
currentPrice: number;
|
||||
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
||||
}) {
|
||||
const pct = fmtPct(unit.price, prevClose);
|
||||
const displayVal = unitDisplayValue(unit, displayMode);
|
||||
const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0;
|
||||
const isLive = isLivePrice(unit.price, currentPrice);
|
||||
return (
|
||||
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'bid') : undefined}>
|
||||
<td className={`ob-td ob-td-pct ob-td-pct--left ${pctClass(pct)}`}>{pct}</td>
|
||||
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
|
||||
<td className={`ob-td ob-td-price${isLive ? ' ob-td-price--live' : ''}`}>{fmtPrice(unit.price)}</td>
|
||||
<td className="ob-td ob-td-qty ob-td-qty--bid">
|
||||
<div className="ob-td-bar-wrap ob-td-bar-wrap--right">
|
||||
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
|
||||
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(barPct, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{formatDisplayValue(displayVal, displayMode)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -257,10 +392,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
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 currentPrice = tickerInfo?.tradePrice ?? 0;
|
||||
const clock = useClock();
|
||||
const isUp = (tickerInfo?.changeRate ?? 0) >= 0;
|
||||
const [summaryMode, setSummaryMode] = useState<SummaryDisplayMode>('amount');
|
||||
|
||||
const maxDisplay = useMemo(() => {
|
||||
const all = [...asks, ...bids];
|
||||
if (all.length === 0) return 0;
|
||||
return Math.max(...all.map(u => unitDisplayValue(u, summaryMode)), 0);
|
||||
}, [asks, bids, summaryMode]);
|
||||
|
||||
return (
|
||||
<div className="ob-panel ob-panel--classic">
|
||||
@@ -301,7 +441,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
{asks.map((unit, i) => (
|
||||
<AskTableRow key={`ask-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
|
||||
<AskTableRow
|
||||
key={`ask-${unit.price}-${i}`}
|
||||
unit={unit}
|
||||
prevClose={prevClose}
|
||||
displayMode={summaryMode}
|
||||
maxDisplay={maxDisplay}
|
||||
currentPrice={currentPrice}
|
||||
onClick={onRowClick}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -310,21 +458,6 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
{tickerInfo && <StatsRail tickerInfo={tickerInfo} prevClose={prevClose} />}
|
||||
</div>
|
||||
|
||||
{/* 현재가 (가운데 열 정렬) */}
|
||||
<table className="ob-table ob-table--hoga ob-table--mid">
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
<tr className="ob-tr-mid">
|
||||
<td className="ob-td ob-td--pad" />
|
||||
<td className={`ob-td ob-td-price ob-td-price--current ${isUp ? 'ob-td-price--up' : 'ob-td-price--dn'}`}>
|
||||
<span className="ob-mid-price">{midPrice > 0 ? fmtPrice(midPrice) : '-'}</span>
|
||||
{midPct && <span className={`ob-mid-pct ${pctClass(midPct)}`}>{midPct}</span>}
|
||||
</td>
|
||||
<td className="ob-td ob-td--pad" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 매수 구간 + 좌측 체결 */}
|
||||
<div className="ob-block ob-block--bid">
|
||||
<TradesPanel trades={recentTrades} strength={tradeStrength} />
|
||||
@@ -334,7 +467,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
{bids.map((unit, i) => (
|
||||
<BidTableRow key={`bid-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
|
||||
<BidTableRow
|
||||
key={`bid-${unit.price}-${i}`}
|
||||
unit={unit}
|
||||
prevClose={prevClose}
|
||||
displayMode={summaryMode}
|
||||
maxDisplay={maxDisplay}
|
||||
currentPrice={currentPrice}
|
||||
onClick={onRowClick}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -345,15 +486,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
)}
|
||||
|
||||
{!isEmpty && (
|
||||
<footer className="ob-summary-bar">
|
||||
<div className="ob-summary ob-summary--ask">
|
||||
<span className="ob-summary-num">{fmtTotalSize(totalAskSize)}</span>
|
||||
</div>
|
||||
<div className="ob-summary ob-summary--time">{clock}</div>
|
||||
<div className="ob-summary ob-summary--bid">
|
||||
<span className="ob-summary-num">{fmtTotalSize(totalBidSize)}</span>
|
||||
</div>
|
||||
</footer>
|
||||
<SummaryBar
|
||||
asks={asks}
|
||||
bids={bids}
|
||||
totalAskSize={totalAskSize}
|
||||
totalBidSize={totalBidSize}
|
||||
clock={clock}
|
||||
mode={summaryMode}
|
||||
onModeChange={setSummaryMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user