Files
goldenChart/frontend/src/components/MarketPanel.tsx
T
2026-05-23 15:11:48 +09:00

416 lines
16 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 } from '../hooks/useMarketTicker';
import {
getFavorites, saveFavorites, toggleFavorite,
getHoldings, addHolding, removeHolding,
} from '../utils/marketStorage';
// ─── 타입 ──────────────────────────────────────────────────────────────────
type Tab = 'krw' | 'btc' | 'hold' | 'fav';
type SortKey = 'name' | 'price' | 'change';
type SortDir = 'asc' | 'desc';
export interface MarketPanelProps {
open: boolean;
tickers: Map<string, TickerData>;
loading: boolean;
usdRate: number; // KRW per 1 USD (from KRW-USDT)
activeSymbol: string;
onSymbolSelect: (symbol: string) => void;
}
// ─── 가격 포맷 헬퍼 ────────────────────────────────────────────────────────
function fmtKrw(price: number | null | undefined): string {
if (price == null || !isFinite(price)) return '-';
if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
if (price >= 0.01) return price.toFixed(4);
return price.toFixed(8);
}
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)}%`;
}
// ─── 방향 지시자 삼각형 ────────────────────────────────────────────────────
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;
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, 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">{ticker?.koreanName ?? code}</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, loading, usdRate, activeSymbol, onSymbolSelect,
}) => {
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(''); }, []);
// 컬럼 정렬 토글
const handleSort = useCallback((key: SortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
return key;
}
setSortDir('desc');
return key;
});
}, []);
// 즐겨찾기 토글
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 allKrwMarkets = useMemo(() => {
const keys = Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
// 24h 거래대금 내림차순 정렬
return keys.sort((a, b) =>
(tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0)
);
}, [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 = tickers.get(m)?.koreanName ?? '';
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 = ta?.koreanName ?? a.replace(/^KRW-/, '');
const nb = tb?.koreanName ?? b.replace(/^KRW-/, '');
return mul * na.localeCompare(nb, 'ko');
}
case 'price':
return mul * ((ta?.tradePrice ?? 0) - (tb?.tradePrice ?? 0));
case 'change':
return mul * ((ta?.changeRate ?? 0) - (tb?.changeRate ?? 0));
default:
return 0;
}
});
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir]);
// 보유 탭 "추가" 검색 결과
const holdAddResults = useMemo(() => {
if (!holdSearch) return [];
const q = holdSearch.toUpperCase();
return allKrwMarkets
.filter(m => {
const code = m.replace(/^KRW-/, '');
const name = tickers.get(m)?.koreanName ?? '';
return code.includes(q) || name.includes(holdSearch);
})
.slice(0, 10);
}, [holdSearch, allKrwMarkets, tickers]);
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>{tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')}</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}
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;