추세검색 화면 적용

This commit is contained in:
Macbook
2026-05-26 00:51:07 +09:00
parent 1e950c7db4
commit 1e11884fef
29 changed files with 3753 additions and 71 deletions
+159 -57
View File
@@ -20,6 +20,9 @@ import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/sma
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
import { isUpbitMarket } from '../utils/upbitApi';
import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData';
import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash';
import VirtualLiveBadge from './virtual/VirtualLiveBadge';
import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import { invalidateMarketCache } from '../utils/requestCache';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
@@ -39,6 +42,24 @@ import type { ChartManager } from '../utils/ChartManager';
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
function compactKoName(market: string): string {
const ko = getKoreanName(market);
if (ko && ko !== market) return ko;
return market.replace(/^KRW-/, '');
}
function wsToLiveStatus(status: WsStatus): VirtualLiveStatus {
switch (status) {
case 'connected': return 'live';
case 'connecting': return 'connecting';
case 'disconnected':
case 'error':
return 'disconnected';
default:
return 'idle';
}
}
// ── Ws 배지 ───────────────────────────────────────────────────────────────
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
if (!isUpbit) return null;
@@ -139,6 +160,8 @@ export interface ChartSlotProps {
chartVolumeVisible?: boolean;
chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
displayTimezone?: string;
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
compactMode?: boolean;
}
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
@@ -159,6 +182,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
chartVolumeVisible = true,
chartRealtimeSource = 'BACKEND_STOMP',
displayTimezone,
compactMode = false,
}, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
@@ -489,6 +513,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
const receiveSignal = useMemo(() => {
if (!useUpbit || wsStatus !== 'connected' || !latestBar) return null;
return `${latestBar.time}|${latestBar.close}|${latestBar.volume}`;
}, [useUpbit, wsStatus, latestBar]);
const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected');
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
@@ -564,36 +595,127 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
setShowMarket(false);
}, []);
const displayKoName = compactKoName(symbol);
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
return (
<div
className={`chart-slot${active ? ' active' : ''}`}
className={[
'chart-slot',
active ? 'active' : '',
compactMode ? 'chart-slot--compact' : '',
receiving ? 'chart-slot--receiving' : '',
trendUp ? 'chart-slot--trend-up' : '',
trendDown ? 'chart-slot--trend-down' : '',
].filter(Boolean).join(' ')}
onClick={onActivate}
>
{/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */}
<div className="slot-header" onClick={e => e.stopPropagation()}>
{/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */}
<button
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
title="종목 변경"
onClick={() => {
if (!showMarket) {
// 클릭 시점에 슬롯 전체(.chart-slot) 컨테이너의 rect를 캡처
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
</svg>
{getKoreanName(symbol) !== symbol
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
: <span className="slot-sym-kr">{symbol}</span>
}
</button>
{/* ── 슬롯 헤더 ──────────────────────────────────────────────────────── */}
<div
className={`slot-header${compactMode ? ' slot-header--compact' : ''}`}
onClick={e => e.stopPropagation()}
>
{compactMode ? (
<div className="slot-compact-row">
<button
type="button"
className={`slot-compact-name${showMarket ? ' open' : ''}`}
title={`${displayKoName} — 종목 변경`}
onClick={() => {
if (!showMarket) {
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
{displayKoName}
</button>
{currentBar && (
<div className="slot-compact-quote" aria-label="현재가">
<span
className="slot-compact-price"
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
>
{formatPrice(currentBar.close)}
</span>
<span
className="slot-compact-change"
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
>
{isUp ? '+' : '-'}{Math.abs(dailyChangePct).toFixed(2)}%
</span>
</div>
)}
<label className="slot-compact-tf" onClick={e => e.stopPropagation()}>
<span className="slot-compact-tf-label"></span>
<select
className="slot-compact-tf-select"
value={timeframe}
aria-label="시간봉 선택"
onChange={e => setTimeframe(e.target.value as Timeframe)}
>
{TF_OPTIONS.map(tf => (
<option key={tf} value={tf}>{tf}</option>
))}
</select>
</label>
<div className="slot-compact-status">
{isLoading && <span className="slot-loading"></span>}
{useUpbit && (
<VirtualLiveBadge status={wsToLiveStatus(wsStatus)} receiving={receiving} />
)}
</div>
</div>
) : (
<>
<button
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
title="종목 변경"
onClick={() => {
if (!showMarket) {
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
</svg>
{getKoreanName(symbol) !== symbol
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
: <span className="slot-sym-kr">{symbol}</span>
}
</button>
<div className="slot-tf-row">
{TF_OPTIONS.map(tf => (
<button
key={tf}
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
{currentBar && (
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{formatPrice(currentBar.close)}
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
</span>
</span>
)}
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
{isLoading && <span className="slot-loading">...</span>}
</>
)}
{/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */}
{showMarket && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={symbol}
@@ -603,44 +725,22 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
/>,
document.body,
)}
{/* 타임프레임 선택 */}
<div className="slot-tf-row">
{TF_OPTIONS.map(tf => (
<button
key={tf}
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
{/* 현재가 */}
{currentBar && (
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{formatPrice(currentBar.close)}
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
</span>
</span>
)}
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
{isLoading && <span className="slot-loading">...</span>}
</div>
{/* ── TradingChart ─────────────────────────────────────────────────── */}
<div
ref={slotContainerRef}
className="slot-chart-wrap"
className={compactMode ? 'vtd-card-chart-canvas slot-chart-wrap slot-chart-wrap--compact' : 'slot-chart-wrap'}
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
>
{useUpbit && isLoading && (
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
<div className="loading-spinner" />
</div>
compactMode ? (
<div className="vtd-card-chart-loading"> </div>
) : (
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
<div className="loading-spinner" />
</div>
)
)}
{isLoadingMore && (
<div className="chart-history-loading">
@@ -663,8 +763,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
drawingTool="cursor"
drawings={drawings}
logScale={false}
drawingsLocked={false}
drawingsVisible={true}
drawingsLocked={compactMode}
drawingsVisible={!compactMode}
showHoverToolbar={!compactMode}
volumeVisible={chartVolumeVisible}
onCrosshair={data => {
setLegend(data);
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);