Files
goldenChart/frontend/src/components/MarketSearchPanel.tsx
T
2026-06-11 23:24:29 +09:00

539 lines
21 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { setMarketNames } from '../utils/marketNameCache';
import {
getFavorites,
toggleFavorite,
FAVORITES_CHANGED_EVENT,
} from '../utils/marketStorage';
import { API_BASE } from '../utils/backendApi';
import { UPBIT_API } from '../utils/upbitApi';
import { fetchTickersThrottled } from '../utils/upbitTickerFetch';
import { useIsCompactDevice } from '../hooks/useMediaQuery';
import { safeToFixed } from '../utils/safeFormat';
import { VirtualScroll } from './common/VirtualScroll';
// ─── Types ───────────────────────────────────────────────────────────────────
interface MarketInfo {
market: string; // "KRW-BTC"
korean_name: string; // "비트코인"
english_name: string; // "Bitcoin"
symbol: string; // "BTC"
}
interface TickerInfo {
trade_price: number;
signed_change_rate: number; // 전일 대비 변화율 (소수, e.g. 0.015 = +1.5%)
acc_trade_price_24h: number; // 24h 거래대금 (KRW)
}
// ─── Constants ───────────────────────────────────────────────────────────────
const TICKER_TTL_DESKTOP = 5000;
const TICKER_TTL_COMPACT = 25_000;
function formatVol(vol: number | string | null | undefined): string {
const v = Number(vol);
if (!Number.isFinite(v)) return '-';
if (v >= 1e12) return `${(v / 1e12).toFixed(1)}조`;
if (v >= 1e8) return `${(v / 1e8).toFixed(0)}억`;
if (v >= 1e4) return `${(v / 1e4).toFixed(0)}만`;
return v.toLocaleString();
}
function formatChangeRate(raw: number | string | null | undefined): number | null {
const rate = Number(raw);
if (!Number.isFinite(rate)) return null;
return rate * 100;
}
type SortKey = 'name' | 'price' | 'change' | 'volume';
type SortDir = 'asc' | 'desc';
// ─── Component ───────────────────────────────────────────────────────────────
interface MarketSearchPanelProps {
currentMarket: string;
onSelect: (market: string) => void;
onClose: () => void;
/**
* 슬롯/버튼 rect. anchorPlacement 에 따라 패널 위치가 결정된다.
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
*/
anchorRect?: DOMRect | null;
/** slot=슬롯 영역 채움, dropdown=클릭 지점 아래 드롭다운 */
anchorPlacement?: 'slot' | 'dropdown';
/** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */
variant?: 'overlay' | 'embedded';
/** 외부 입력과 검색어 동기화 */
initialQuery?: string;
/** 제어형 검색어 (embedded 등 외부 input 사용 시) */
query?: string;
onQueryChange?: (query: string) => void;
/** favorite=별표, add=투자대상 추가(+) */
actionMode?: 'favorite' | 'add';
/** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
/** actionMode=add 일 때 이미 추가된 종목 클릭 → 투자대상에서 제거 */
onRemove?: (market: string) => void;
/** 이미 추가된 종목 (add 모드에서 체크 표시·클릭 시 제거) */
addedMarkets?: Set<string>;
/** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */
maxTargetsReached?: boolean;
}
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
variant = 'overlay', query: controlledQuery, onQueryChange,
actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
}) => {
const embedded = variant === 'embedded';
const isCompact = useIsCompactDevice();
const tickerTtl = isCompact ? TICKER_TTL_COMPACT : TICKER_TTL_DESKTOP;
const [markets, setMarkets] = useState<MarketInfo[]>([]);
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
const [internalQuery, setInternalQuery] = useState(initialQuery);
const query = controlledQuery ?? internalQuery;
const setQuery = useCallback((next: string) => {
if (controlledQuery === undefined) setInternalQuery(next);
onQueryChange?.(next);
}, [controlledQuery, onQueryChange]);
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [loading, setLoading] = useState(true);
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('desc');
const refreshFavs = useCallback(() => {
setFavs(new Set(getFavorites()));
}, []);
const inputRef = useRef<HTMLInputElement>(null);
const tickerTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const marketCodesRef = useRef<string>('');
// ── 1. 마켓 목록 로드 ─────────────────────────────────────────────────────
useEffect(() => {
setLoading(true);
fetch(`${API_BASE}/markets/all?isDetails=false`)
.then(r => {
if (!r.ok) return fetch(`${UPBIT_API}/market/all?isDetails=false`);
return r;
})
.then(r => {
if (!r.ok) throw new Error(`market/all ${r.status}`);
return r.json();
})
.then((data: Array<{ market: string; korean_name: string; english_name: string }>) => {
const krw = data
.filter(m => m.market.startsWith('KRW-'))
.map(m => ({ ...m, symbol: m.market.replace('KRW-', '') }));
setMarkets(krw);
marketCodesRef.current = krw.map(m => m.market).join(',');
// 한글명 캐시 저장 (Toolbar 등에서 사용)
const nameMap: Record<string, string> = {};
for (const m of krw) nameMap[m.market] = m.korean_name;
setMarketNames(nameMap);
setLoading(false);
})
.catch(() => setLoading(false));
if (!embedded) setTimeout(() => inputRef.current?.focus(), 50);
refreshFavs();
}, [refreshFavs, embedded]);
useEffect(() => {
if (controlledQuery === undefined) setInternalQuery(initialQuery);
}, [initialQuery, controlledQuery]);
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
useEffect(() => {
const onChanged = () => refreshFavs();
window.addEventListener(FAVORITES_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChanged);
}, [refreshFavs]);
// ── 2. 시세 데이터 폴링 (100개씩 배치 요청) ─────────────────────────────
const fetchTickers = useCallback(async () => {
const codes = marketCodesRef.current;
if (!codes) return;
const allCodes = codes.split(',').filter(Boolean);
const rawList = await fetchTickersThrottled(allCodes);
setTickers(prev => {
const map: Record<string, TickerInfo> = { ...prev };
for (const t of rawList) {
const market = (t.market ?? t.code) as string;
if (!market) continue;
map[market] = {
trade_price: t.trade_price,
signed_change_rate: t.signed_change_rate,
acc_trade_price_24h: t.acc_trade_price_24h,
};
}
return map;
});
}, []);
useEffect(() => {
if (markets.length === 0) return;
fetchTickers();
tickerTimer.current = setInterval(fetchTickers, tickerTtl);
return () => { if (tickerTimer.current) clearInterval(tickerTimer.current); };
}, [markets, fetchTickers, tickerTtl]);
// ── 3. 즐겨찾기 토글 ─────────────────────────────────────────────────────
const toggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
e.stopPropagation();
const row = markets.find(m => m.market === market);
try {
const next = await toggleFavorite(market, {
koreanName: row?.korean_name,
englishName: row?.english_name,
});
setFavs(new Set(next));
} catch (err) {
console.warn('[MarketSearchPanel] watchlist toggle failed', err);
}
}, [markets]);
const tryAddMarket = useCallback((market: string) => {
if (actionMode === 'add') {
onSelect(market);
if (addedMarkets?.has(market)) {
if (embedded) onClose();
return;
}
if (maxTargetsReached) return;
const row = markets.find(m => m.market === market);
onAdd?.(market, {
koreanName: row?.korean_name ?? market,
englishName: row?.english_name ?? market,
});
if (embedded) onClose();
return;
}
onSelect(market);
if (!embedded) onClose();
}, [actionMode, onSelect, onAdd, addedMarkets, maxTargetsReached, markets, embedded, onClose]);
const handleAddClick = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
tryAddMarket(market);
}, [tryAddMarket]);
const handleSort = useCallback((key: SortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
return key;
}
setSortDir('desc');
return key;
});
}, []);
const renderSortHeader = (key: SortKey, label: string, className: string) => {
const active = sortKey === key;
return (
<button
type="button"
className={`msp-col ${className} msp-h-sort-btn ${active ? 'msp-h-sort-btn--active' : ''}`}
onClick={() => handleSort(key)}
title={active ? (sortDir === 'asc' ? '내림차순 정렬' : '오름차순 정렬') : `${label} 정렬`}
>
{label}
<span className="msp-sort-icon">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</button>
);
};
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
const q = query.trim();
const filtered = markets.filter(m => {
if (!q) return true;
return (
m.korean_name.includes(q) ||
m.symbol.toUpperCase().includes(q.toUpperCase()) ||
m.english_name.toLowerCase().includes(q.toLowerCase()) ||
m.market.toUpperCase().includes(q.toUpperCase())
);
});
const sorted = useMemo(() => {
if (!sortKey) {
return [...filtered].sort((a, b) => {
const fa = favs.has(a.market) ? 1 : 0;
const fb = favs.has(b.market) ? 1 : 0;
if (fb !== fa) return fb - fa;
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
return vb - va;
});
}
const mul = sortDir === 'asc' ? 1 : -1;
return [...filtered].sort((a, b) => {
switch (sortKey) {
case 'name':
return mul * a.korean_name.localeCompare(b.korean_name, 'ko');
case 'price': {
const pa = tickers[a.market]?.trade_price ?? 0;
const pb = tickers[b.market]?.trade_price ?? 0;
return mul * (pa - pb);
}
case 'change': {
const ca = tickers[a.market]?.signed_change_rate ?? 0;
const cb = tickers[b.market]?.signed_change_rate ?? 0;
return mul * (ca - cb);
}
case 'volume': {
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
return mul * (va - vb);
}
default:
return 0;
}
});
}, [filtered, sortKey, sortDir, favs, tickers]);
// ── Render ────────────────────────────────────────────────────────────────
// anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다.
const HEADER_H = 30; // 슬롯 헤더 높이 (약 30px)
const MIN_W = 260;
// LWC v5 canvas 레이어가 내부적으로 높은 z-index를 사용하므로
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
const PANEL_Z = 99999;
const DROPDOWN_H = 480;
const isCompactDropdown = anchorPlacement === 'dropdown' && !embedded;
const panelStyle: React.CSSProperties | undefined = embedded
? undefined
: anchorRect
? anchorPlacement === 'dropdown'
? {
position: 'fixed',
top: anchorRect.bottom + 4,
left: anchorRect.left,
width: Math.max(420, anchorRect.width, MIN_W),
height: Math.min(DROPDOWN_H, window.innerHeight - anchorRect.bottom - 12),
maxWidth: Math.min(520, window.innerWidth - anchorRect.left - 8),
minHeight: 320,
zIndex: PANEL_Z,
background: 'var(--bg2)',
color: 'var(--text)',
border: '1px solid var(--border)',
}
: {
position: 'fixed',
top: anchorRect.top + HEADER_H,
left: anchorRect.left,
width: Math.max(anchorRect.width, MIN_W),
height: anchorRect.height - HEADER_H,
maxWidth: anchorRect.width,
zIndex: PANEL_Z,
background: 'var(--bg2)',
color: 'var(--text)',
border: '1px solid var(--border)',
}
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
// backdrop: dropdown=전체 화면, slot=해당 슬롯 영역만
const backdropStyle: React.CSSProperties | undefined = anchorRect
? anchorPlacement === 'dropdown'
? {
position: 'fixed',
inset: 0,
zIndex: PANEL_Z - 1,
background: 'rgba(0,0,0,0.28)',
}
: {
position: 'fixed',
top: anchorRect.top,
left: anchorRect.left,
width: anchorRect.width,
height: anchorRect.height,
zIndex: PANEL_Z - 1,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'none',
}
: undefined;
const handleRowSelect = useCallback((market: string) => {
tryAddMarket(market);
}, [tryAddMarket]);
return (
<>
{!embedded && (
<div
className="msp-backdrop"
style={backdropStyle}
onClick={onClose}
/>
)}
<div
className={[
'msp-panel',
anchorRect && anchorPlacement === 'dropdown' ? 'msp-panel--dropdown' : '',
anchorRect && anchorPlacement === 'slot' ? 'msp-panel--slot' : '',
embedded ? 'msp-panel--embedded' : '',
].filter(Boolean).join(' ')}
style={panelStyle}
>
{!embedded && (
<div className="msp-search-row">
<span className="msp-search-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</span>
<input
ref={inputRef}
className="msp-input"
placeholder="종목명 또는 심볼 검색"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter' && sorted.length > 0) handleRowSelect(sorted[0].market);
}}
/>
{query && (
<button className="msp-btn-icon" title="초기화" onClick={() => setQuery('')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
<button className="msp-btn-icon" title="닫기" onClick={onClose}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
</div>
)}
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}${isCompactDropdown ? ' msp-header--compact' : ''}`}>
{!isCompactDropdown && (
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
)}
{renderSortHeader('name', isCompactDropdown ? '종목명' : '한글명', 'msp-col-name')}
{!embedded && (
<>
{renderSortHeader('price', '현재가', 'msp-col-price')}
{renderSortHeader('change', '전일대비', 'msp-col-change')}
{!isCompactDropdown && renderSortHeader('volume', '거래대금', 'msp-col-vol')}
</>
)}
</div>
{/* 목록 — 가상 스크롤 (업비트 전 종목) */}
{loading ? (
<div className="msp-list">
<div className="msp-empty">마켓 목록 로딩 ...</div>
</div>
) : (
<VirtualScroll
className="msp-list"
items={sorted}
estimateSize={embedded ? 34 : isCompactDropdown ? 36 : 38}
measureDynamic
getItemKey={m => m.market}
empty={<div className="msp-empty">검색 결과가 없습니다</div>}
aria-label="종목 검색 결과"
>
{m => {
const tk = tickers[m.market];
const isFav = favs.has(m.market);
const isAdded = addedMarkets?.has(m.market) ?? false;
const addBlocked = maxTargetsReached && !isAdded;
const isActive = m.market === currentMarket;
const rate = tk ? formatChangeRate(tk.signed_change_rate) : null;
const isUp = rate !== null && rate >= 0;
return (
<div
className={[
'msp-item',
embedded ? 'msp-item--embedded' : '',
isCompactDropdown ? 'msp-item--compact' : '',
isActive ? 'active' : '',
].filter(Boolean).join(' ')}
onClick={() => handleRowSelect(m.market)}
>
{!isCompactDropdown && (
actionMode === 'add' ? (
<button
type="button"
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`}
onClick={e => handleAddClick(m.market, e)}
disabled={addBlocked}
title={
isAdded
? '투자대상에서 제거'
: addBlocked
? '투자대상 최대 개수에 도달했습니다'
: '투자대상에 추가'
}
>
{isAdded ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
)}
</button>
) : (
<span
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
onClick={e => toggleFav(m.market, e)}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
{isFav ? '★' : '☆'}
</span>
)
)}
<span className="msp-col msp-col-name">
<span className="msp-kor">{m.korean_name}</span>
{!isCompactDropdown && <span className="msp-sym">{m.symbol}</span>}
</span>
{!embedded && (
<>
<span className="msp-col msp-col-price">
{tk && Number.isFinite(Number(tk.trade_price))
? Number(tk.trade_price).toLocaleString()
: '-'}
</span>
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
{rate === null
? '-'
: `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`}
</span>
{!isCompactDropdown && (
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span>
)}
</>
)}
</div>
);
}}
</VirtualScroll>
)}
</div>
</>
);
};
export default MarketSearchPanel;