실시간 차트 멀티모드 수정
This commit is contained in:
@@ -24,6 +24,7 @@ import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash';
|
||||
import VirtualLiveBadge from './virtual/VirtualLiveBadge';
|
||||
import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
||||
import { invalidateMarketCache } from '../utils/requestCache';
|
||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||
import {
|
||||
@@ -162,6 +163,8 @@ export interface ChartSlotProps {
|
||||
displayTimezone?: string;
|
||||
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
|
||||
compactMode?: boolean;
|
||||
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
}
|
||||
|
||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||
@@ -183,6 +186,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
displayTimezone,
|
||||
compactMode = false,
|
||||
chartLiveReceiveHighlight = true,
|
||||
}, ref) {
|
||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||
@@ -200,10 +204,11 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
const [showMainChartModal, setShowMainChartModal] = useState(false);
|
||||
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(DEFAULT_MAIN_CHART_STYLE);
|
||||
const [showMarket, setShowMarket] = useState(false);
|
||||
// 슬롯 컨테이너 rect: MarketSearchPanel 위치 계산에 사용
|
||||
// MarketSearchPanel 위치: compact=종목명 버튼, 일반=슬롯 전체
|
||||
const [slotAnchorRect, setSlotAnchorRect] = useState<DOMRect | null>(null);
|
||||
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const compactNameRef = useRef<HTMLButtonElement>(null);
|
||||
// initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장
|
||||
const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
|
||||
const timeframeRef = useRef<Timeframe>(forcedTf ?? '1D');
|
||||
@@ -362,6 +367,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
// → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결)
|
||||
const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null);
|
||||
const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null);
|
||||
/** 종목·TF 전환 직후 sync range 재적용 스킵 (이전 종목 뷰포트가 새 차트에 적용되는 것 방지) */
|
||||
const skipSyncApplyRef = useRef(false);
|
||||
|
||||
// ── Crosshair sync: syncTime → setCrosshairAtTime ────────────────────
|
||||
useEffect(() => {
|
||||
@@ -371,7 +378,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
|
||||
// ── Time sync: syncVisibleRange 외부 적용 ────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!syncVisibleRange) return;
|
||||
if (!syncVisibleRange || skipSyncApplyRef.current) return;
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || mgr.getRawBarsLength() === 0) {
|
||||
// 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용
|
||||
@@ -385,7 +392,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
|
||||
// ── Date range sync: syncLogicalRange 외부 적용 ──────────────────────
|
||||
useEffect(() => {
|
||||
if (!syncLogicalRange) return;
|
||||
if (!syncLogicalRange || skipSyncApplyRef.current) return;
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || mgr.getRawBarsLength() === 0) {
|
||||
pendingSyncLogicalRef.current = syncLogicalRange;
|
||||
@@ -409,6 +416,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
reloadTriggeredRef.current = false;
|
||||
chartLiveReadyRef.current = false;
|
||||
pendingRealtimeBarRef.current = null;
|
||||
pendingSyncVisibleRef.current = null;
|
||||
pendingSyncLogicalRef.current = null;
|
||||
skipSyncApplyRef.current = true;
|
||||
}, [symbol, timeframe]);
|
||||
useEffect(() => {
|
||||
const checks = [200, 500, 1000, 2000].map(delay =>
|
||||
@@ -519,6 +529,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
}, [useUpbit, wsStatus, latestBar]);
|
||||
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected');
|
||||
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
||||
|
||||
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
|
||||
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
|
||||
@@ -590,11 +601,29 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
// ── 심볼 선택 ─────────────────────────────────────────────────────────
|
||||
const handleSymbolSelect = useCallback((s: string) => {
|
||||
if (isUpbitMarket(s)) invalidateMarketCache(s);
|
||||
pendingSyncVisibleRef.current = null;
|
||||
pendingSyncLogicalRef.current = null;
|
||||
skipSyncApplyRef.current = true;
|
||||
setSymbol(s);
|
||||
setLegend(null);
|
||||
setShowMarket(false);
|
||||
setSlotAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
const openMarketSearch = useCallback((e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
onActivate();
|
||||
if (!showMarket) {
|
||||
const anchorEl = compactMode
|
||||
? compactNameRef.current
|
||||
: (slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null);
|
||||
setSlotAnchorRect(anchorEl?.getBoundingClientRect() ?? null);
|
||||
} else {
|
||||
setSlotAnchorRect(null);
|
||||
}
|
||||
setShowMarket(v => !v);
|
||||
}, [compactMode, onActivate, showMarket]);
|
||||
|
||||
const displayKoName = compactKoName(symbol);
|
||||
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
|
||||
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
|
||||
@@ -605,7 +634,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
'chart-slot',
|
||||
active ? 'active' : '',
|
||||
compactMode ? 'chart-slot--compact' : '',
|
||||
receiving ? 'chart-slot--receiving' : '',
|
||||
highlightReceiving ? 'chart-slot--receiving' : '',
|
||||
trendUp ? 'chart-slot--trend-up' : '',
|
||||
trendDown ? 'chart-slot--trend-down' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
@@ -620,17 +649,17 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
<div className="slot-compact-row">
|
||||
<button
|
||||
type="button"
|
||||
ref={compactNameRef}
|
||||
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);
|
||||
}}
|
||||
aria-expanded={showMarket}
|
||||
aria-haspopup="listbox"
|
||||
onClick={openMarketSearch}
|
||||
>
|
||||
{displayKoName}
|
||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }} aria-hidden>
|
||||
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
||||
</svg>
|
||||
<span className="slot-compact-name-text">{displayKoName}</span>
|
||||
</button>
|
||||
|
||||
{currentBar && (
|
||||
@@ -676,13 +705,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
<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);
|
||||
}}
|
||||
onClick={openMarketSearch}
|
||||
>
|
||||
<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"/>
|
||||
@@ -720,8 +743,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
<MarketSearchPanel
|
||||
currentMarket={symbol}
|
||||
onSelect={s => { handleSymbolSelect(s); }}
|
||||
onClose={() => setShowMarket(false)}
|
||||
onClose={() => { setShowMarket(false); setSlotAnchorRect(null); }}
|
||||
anchorRect={slotAnchorRect}
|
||||
anchorPlacement={compactMode ? 'dropdown' : 'slot'}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -809,6 +833,12 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
if (skipSyncApplyRef.current) {
|
||||
skipSyncApplyRef.current = false;
|
||||
const fb = mgr.hasIchimoku() ? 28 : 0;
|
||||
mgr.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
||||
return;
|
||||
}
|
||||
if (pendingSyncVisibleRef.current) {
|
||||
isSyncingTimeRef.current = true;
|
||||
mgr.applyVisibleTimeRange(
|
||||
|
||||
Reference in New Issue
Block a user