추세검색 화면 적용
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user