실시간 차트 멀티모드 수정

This commit is contained in:
Macbook
2026-05-26 01:16:36 +09:00
parent 1e11884fef
commit 173b47d485
15 changed files with 302 additions and 121 deletions
+50 -20
View File
@@ -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(