Files
goldenChart/frontend/src/components/MarketPanel.tsx
T
2026-06-16 08:53:35 +09:00

452 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 좌측 마켓 패널
*
* 탭 구성:
* 원화 - KRW 마켓 목록, 현재가(KRW)
* BTC - KRW 마켓 목록, 현재가(USD 환산)
* 보유 - 보유 종목 목록 (localStorage 관리)
* 관심 - 즐겨찾기 목록 (localStorage 관리)
*/
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
import type { TickerData, ChangeDir, MarketInfo } from '../hooks/useMarketTicker';
import {
getFavorites, saveFavorites, toggleFavorite,
getHoldings, addHolding, removeHolding,
} from '../utils/marketStorage';
import { formatUpbitKrwPrice } from '../utils/safeFormat';
// ─── 타입 ──────────────────────────────────────────────────────────────────
type Tab = 'krw' | 'btc' | 'hold' | 'fav';
type SortKey = 'name' | 'price' | 'change';
type SortDir = 'asc' | 'desc';
export interface MarketPanelProps {
open: boolean;
tickers: Map<string, TickerData>;
/** 업비트 KRW 전 종목 (종목검색 드롭다운과 동일 소스) */
marketInfos: MarketInfo[];
loading: boolean;
usdRate: number; // KRW per 1 USD (from KRW-USDT)
activeSymbol: string;
onSymbolSelect: (symbol: string) => void;
/** 정렬 전 전 종목 시세 재조회 (useMarketTicker.refreshAllTickers) */
refreshAllTickers?: () => Promise<void>;
}
// ─── 가격 포맷 헬퍼 ────────────────────────────────────────────────────────
function fmtKrw(price: number | null | undefined): string {
return formatUpbitKrwPrice(price, '-');
}
function fmtUsd(krwPrice: number | null | undefined, rate: number): string {
if (krwPrice == null || !isFinite(krwPrice) || !rate) return '-';
const usd = krwPrice / rate;
if (usd >= 10_000) return '$' + usd.toLocaleString('en-US', { maximumFractionDigits: 0 });
if (usd >= 1) return '$' + usd.toFixed(2);
if (usd >= 0.001) return '$' + usd.toFixed(4);
return '$' + usd.toFixed(6);
}
function fmtPct(rate: number | null | undefined): string {
if (rate == null || !isFinite(rate)) return '-';
const sign = rate >= 0 ? '+' : '';
return `${sign}${(rate * 100).toFixed(2)}%`;
}
function compareNullableNum(
a: number | null | undefined,
b: number | null | undefined,
mul: number,
): number {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return mul * (a - b);
}
// ─── 방향 지시자 삼각형 ────────────────────────────────────────────────────
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {
if (change === 'RISE') return <span className="mp-arrow mp-arrow--rise"></span>;
if (change === 'FALL') return <span className="mp-arrow mp-arrow--fall"></span>;
return <span className="mp-arrow mp-arrow--even"></span>;
});
// ─── 개별 행 ───────────────────────────────────────────────────────────────
interface RowProps {
ticker: TickerData | undefined;
market: string;
koreanName: string;
isActive: boolean;
isFav: boolean;
isHold: boolean;
showUsd: boolean;
showHoldBtn: boolean;
usdRate: number;
onSelect: () => void;
onToggleFav: (e: React.MouseEvent) => void;
onToggleHold: (e: React.MouseEvent) => void;
onRemoveHold?: (e: React.MouseEvent) => void;
}
const MarketRow = memo(function MarketRow({
ticker, market, koreanName, isActive, isFav, isHold,
showUsd, showHoldBtn, usdRate,
onSelect, onToggleFav, onToggleHold, onRemoveHold,
}: RowProps) {
const change: ChangeDir = ticker?.change ?? 'EVEN';
const code = market.replace(/^KRW-/, '');
// ── 가격 변동 플래시 (frontend_golden TickerItem 로직) ─────────────────
const [flashClass, setFlashClass] = useState('');
const prevPriceRef = useRef<number | null>(null);
useEffect(() => {
const price = ticker?.tradePrice ?? null;
if (price === null) return;
const prev = prevPriceRef.current;
if (prev !== null && prev !== price) {
const cls = price > prev ? 'mp-row--flash-rise' : 'mp-row--flash-fall';
setFlashClass(cls);
const t = setTimeout(() => setFlashClass(''), 700);
prevPriceRef.current = price;
return () => clearTimeout(t);
}
prevPriceRef.current = price;
}, [ticker?.tradePrice]);
const colorCls = change === 'RISE' ? 'mp-up' : change === 'FALL' ? 'mp-dn' : 'mp-even';
return (
<div
className={`mp-row ${isActive ? 'mp-row--active' : ''} ${flashClass}`}
onClick={onSelect}
>
{/* 즐겨찾기 별 */}
<button
className={`mp-star ${isFav ? 'mp-star--on' : ''}`}
onClick={onToggleFav}
title={isFav ? '관심 해제' : '관심 등록'}
>
{isFav ? '★' : '☆'}
</button>
{/* 이름 + 방향 지시자 */}
<div className="mp-name">
<span className="mp-korean-row">
<ChangeArrow change={change} />
<span className="mp-korean">{koreanName}</span>
</span>
<span className="mp-code">{code}/KRW</span>
</div>
{/* 현재가 */}
<span className={`mp-price ${colorCls}`}>
{ticker
? (showUsd ? fmtUsd(ticker.tradePrice, usdRate) : fmtKrw(ticker.tradePrice))
: '-'}
</span>
{/* 전일대비 */}
<span className={`mp-change ${colorCls}`}>
{ticker ? fmtPct(ticker.changeRate) : '-'}
</span>
{/* 보유 토글 / 제거 버튼 */}
{onRemoveHold ? (
<button className="mp-hold-rm" onClick={onRemoveHold} title="보유 종목 제거">×</button>
) : showHoldBtn ? (
<button
className={`mp-hold-btn ${isHold ? 'mp-hold-btn--on' : ''}`}
onClick={onToggleHold}
title={isHold ? '보유 해제' : '보유 등록'}
>
{isHold ? '₩' : '+₩'}
</button>
) : null}
</div>
);
});
// ─── 메인 패널 ─────────────────────────────────────────────────────────────
export const MarketPanel: React.FC<MarketPanelProps> = ({
open, tickers, marketInfos, loading, usdRate, activeSymbol, onSymbolSelect,
refreshAllTickers,
}) => {
const [tab, setTab] = useState<Tab>('krw');
const [search, setSearch] = useState('');
const [favorites, setFavs] = useState<string[]>(getFavorites);
const [holdings, setHolds] = useState<string[]>(getHoldings);
const [holdSearch, setHoldSrch] = useState('');
const [showHoldAdd, setShowHoldAdd] = useState(false);
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('desc');
const searchRef = useRef<HTMLInputElement>(null);
// 패널 열릴 때 검색창 포커스
useEffect(() => { if (open) setTimeout(() => searchRef.current?.focus(), 250); }, [open]);
// 검색 초기화 (탭 전환 시)
const handleTab = useCallback((t: Tab) => { setTab(t); setSearch(''); }, []);
// 컬럼 정렬 토글 (시세 기반 정렬 전 전 종목 REST 재조회)
const applySortState = useCallback((key: SortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
return key;
}
setSortDir('desc');
return key;
});
}, []);
const handleSort = useCallback((key: SortKey) => {
const needsRefresh = key === 'change' || key === 'price';
if (needsRefresh && refreshAllTickers) {
void refreshAllTickers().then(() => applySortState(key));
return;
}
applySortState(key);
}, [refreshAllTickers, applySortState]);
// 즐겨찾기 토글
const handleToggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
e.stopPropagation();
const row = tickers.get(market);
try {
const next = await toggleFavorite(market, { koreanName: row?.koreanName });
setFavs(next);
} catch (err) {
console.warn('[MarketPanel] watchlist toggle failed', err);
}
}, [tickers]);
// 보유 토글
const handleToggleHold = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
setHolds(prev =>
prev.includes(market) ? removeHolding(market) : addHolding(market)
);
}, []);
// 보유 제거 (보유 탭)
const handleRemoveHold = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
setHolds(removeHolding(market));
}, []);
// 보유 추가 검색에서 선택
const handleAddHolding = useCallback((market: string) => {
setHolds(addHolding(market));
setHoldSrch('');
setShowHoldAdd(false);
}, []);
const koreanNameByMarket = useMemo(() => {
const map = new Map<string, string>();
marketInfos.forEach(m => map.set(m.market, m.koreanName));
return map;
}, [marketInfos]);
const resolveKoreanName = useCallback((market: string) =>
tickers.get(market)?.koreanName
?? koreanNameByMarket.get(market)
?? market.replace(/^KRW-/, ''),
[tickers, koreanNameByMarket]);
// 탭별 마켓 목록 — market/all 전 종목 (종목검색 드롭다운과 동일)
const allKrwMarkets = useMemo(() => {
const keys = marketInfos.length > 0
? marketInfos.map(m => m.market)
: Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
return keys.sort((a, b) =>
(tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0)
);
}, [marketInfos, tickers]);
const filtered = useMemo(() => {
let base: string[] = [];
switch (tab) {
case 'krw': case 'btc': base = allKrwMarkets; break;
case 'hold': base = holdings.filter(m => tickers.has(m) || true); break;
case 'fav': base = favorites; break;
}
if (search) {
const q = search.toUpperCase();
base = base.filter(m => {
const code = m.replace(/^KRW-/, '');
const name = resolveKoreanName(m);
return code.includes(q) || name.includes(search);
});
}
if (!sortKey) return base;
const mul = sortDir === 'asc' ? 1 : -1;
return [...base].sort((a, b) => {
const ta = tickers.get(a);
const tb = tickers.get(b);
switch (sortKey) {
case 'name': {
const na = resolveKoreanName(a);
const nb = resolveKoreanName(b);
return mul * na.localeCompare(nb, 'ko');
}
case 'price':
return compareNullableNum(ta?.tradePrice, tb?.tradePrice, mul);
case 'change':
return compareNullableNum(ta?.changeRate, tb?.changeRate, mul);
default:
return 0;
}
});
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir, resolveKoreanName]);
// 보유 탭 "추가" 검색 결과
const holdAddResults = useMemo(() => {
if (!holdSearch) return [];
const q = holdSearch.toUpperCase();
return allKrwMarkets
.filter(m => {
const code = m.replace(/^KRW-/, '');
const name = resolveKoreanName(m);
return code.includes(q) || name.includes(holdSearch);
})
.slice(0, 10);
}, [holdSearch, allKrwMarkets, resolveKoreanName]);
const tabLabels: Record<Tab, string> = {
krw: '원화', btc: 'BTC', hold: '보유', fav: '관심',
};
return (
<div className={`market-panel ${open ? 'market-panel--open' : ''}`}>
{/* ── 패널 내용 ─────────────────────────────────────────────── */}
<div className="mp-body mp-body--full">
{/* 탭 헤더 */}
<div className="mp-tabs">
{(['krw', 'btc', 'hold', 'fav'] as Tab[]).map(t => (
<button
key={t}
className={`mp-tab ${tab === t ? 'mp-tab--active' : ''}`}
onClick={() => handleTab(t)}
>
{tabLabels[t]}
</button>
))}
</div>
{/* 검색 */}
<div className="mp-search-row">
<div className="mp-search-box">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" opacity={0.5}>
<circle cx="5.5" cy="5.5" r="4"/><line x1="9" y1="9" x2="12.5" y2="12.5"/>
</svg>
<input
ref={searchRef}
className="mp-search-input"
type="text"
placeholder="코인명/심볼검색"
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button className="mp-search-clear" onClick={() => setSearch('')}></button>
)}
</div>
</div>
{/* 컬럼 헤더 */}
<div className="mp-list-header">
{(['name', 'price', 'change'] as SortKey[]).map(key => {
const label = key === 'name' ? '한글명' : key === 'price' ? '현재가' : '전일대비';
const cls = key === 'name' ? 'mp-h-name' : key === 'price' ? 'mp-h-price' : 'mp-h-change';
const active = sortKey === key;
return (
<button
key={key}
className={`${cls} mp-h-sort-btn ${active ? 'mp-h-sort-btn--active' : ''}`}
onClick={() => handleSort(key)}
title={active ? (sortDir === 'asc' ? '내림차순 정렬' : '오름차순 정렬') : `${label} 정렬`}
>
{label}
<span className="mp-sort-icon">
{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}
</span>
</button>
);
})}
</div>
{/* 보유 탭: 추가 버튼 */}
{tab === 'hold' && (
<div className="mp-hold-add-area">
{showHoldAdd ? (
<div className="mp-hold-add-form">
<input
className="mp-hold-add-input"
placeholder="종목 검색..."
value={holdSearch}
onChange={e => setHoldSrch(e.target.value)}
autoFocus
/>
<button className="mp-hold-add-cancel" onClick={() => { setShowHoldAdd(false); setHoldSrch(''); }}>취소</button>
{holdAddResults.length > 0 && (
<div className="mp-hold-add-dropdown">
{holdAddResults.map(m => (
<div key={m} className="mp-hold-add-item" onClick={() => handleAddHolding(m)}>
<span>{resolveKoreanName(m)}</span>
<span className="mp-hold-add-code">{m.replace(/^KRW-/, '')}</span>
</div>
))}
</div>
)}
</div>
) : (
<button className="mp-hold-add-btn" onClick={() => setShowHoldAdd(true)}>
+ 보유 종목 추가
</button>
)}
</div>
)}
{/* 마켓 목록 */}
<div className="mp-list">
{loading && (
<div className="mp-loading">
<div className="loading-spinner" style={{ width: 18, height: 18 }} />
<span>마켓 정보 로딩 ...</span>
</div>
)}
{!loading && filtered.length === 0 && (
<div className="mp-empty">
{tab === 'hold' ? '보유 종목이 없습니다' :
tab === 'fav' ? '관심 종목이 없습니다' :
'검색 결과 없음'}
</div>
)}
{!loading && filtered.map(market => (
<MarketRow
key={market}
market={market}
koreanName={resolveKoreanName(market)}
ticker={tickers.get(market)}
isActive={market === activeSymbol}
isFav={favorites.includes(market)}
isHold={holdings.includes(market)}
showUsd={tab === 'btc'}
showHoldBtn={tab === 'krw' || tab === 'btc' || tab === 'fav'}
usdRate={usdRate}
onSelect={() => onSymbolSelect(market)}
onToggleFav={e => handleToggleFav(market, e)}
onToggleHold={e => handleToggleHold(market, e)}
onRemoveHold={tab === 'hold' ? e => handleRemoveHold(market, e) : undefined}
/>
))}
</div>
</div>
</div>
);
};
export default MarketPanel;