diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 2ba1136..cc3ccf8 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -178,6 +178,10 @@ interface TradingChartProps { custom1OverlayActive?: boolean; onCustom1OverlayActiveChange?: (active: boolean) => void; onOpenCustom1OverlaySettings?: () => void; + /** 미니 차트 — plot 너비에 맞는 초기 x축 캔들 수 (미지정 시 DISPLAY_COUNT) */ + resolveInitialDisplayCount?: (plotWidth: number) => number; + /** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */ + skipViewportRecovery?: boolean; } const TradingChart: React.FC = ({ @@ -231,6 +235,8 @@ const TradingChart: React.FC = ({ custom1OverlayActive, onCustom1OverlayActiveChange, onOpenCustom1OverlaySettings, + resolveInitialDisplayCount, + skipViewportRecovery = false, }) => { const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend; const showOverlayControlsOnly = !!( @@ -316,6 +322,17 @@ const TradingChart: React.FC = ({ const recoveryTimerRef = useRef(null); const resizeDebounceRef = useRef(null); const chartVisibleRef = useRef(chartVisible); + const resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount); + const skipViewportRecoveryRef = useRef(skipViewportRecovery); + const lastViewportFitRef = useRef<{ count: number; width: number } | null>(null); + + useEffect(() => { + resolveInitialDisplayCountRef.current = resolveInitialDisplayCount; + }, [resolveInitialDisplayCount]); + + useEffect(() => { + skipViewportRecoveryRef.current = skipViewportRecovery; + }, [skipViewportRecovery]); const chartTimeFormat = useChartTimeFormat(); const [chartMgr, setChartMgr] = useState(null); @@ -548,15 +565,31 @@ const TradingChart: React.FC = ({ userDraggedRatioRef.current = null; }, [paneAreaRatio]); + const getInitialDisplayCount = useCallback((): number => { + const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0; + const resolver = resolveInitialDisplayCountRef.current; + return resolver ? resolver(plotW) : DISPLAY_COUNT; + }, []); + + const applyInitialVisibleRange = useCallback((mgr: ChartManager) => { + const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0; + if (plotW <= 0) return; + const count = getInitialDisplayCount(); + const prev = lastViewportFitRef.current; + if (prev && prev.count === count && Math.abs(prev.width - plotW) < 4) return; + lastViewportFitRef.current = { count, width: plotW }; + const fb = mgr.hasIchimoku() ? 28 : 0; + mgr.setInitialVisibleRange(count, fb); + }, [getInitialDisplayCount]); + /** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */ const normalizeChartViewport = useCallback((mgr: ChartManager) => { applyPaneLayout(mgr); requestAnimationFrame(() => { try { mgr.autoScale(); } catch { /* ok */ } - const fb = mgr.hasIchimoku() ? 28 : 0; - mgr.setInitialVisibleRange(DISPLAY_COUNT, fb); + applyInitialVisibleRange(mgr); }); - }, [applyPaneLayout]); + }, [applyPaneLayout, applyInitialVisibleRange]); /** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */ useEffect(() => { @@ -573,13 +606,12 @@ const TradingChart: React.FC = ({ const wrapperH = wrapperRef.current?.clientHeight ?? 0; m.restoreFromCandleFullscreen(wrapperH); applyPaneLayout(m); - const futureBars = m.hasIchimoku() ? 28 : 0; - m.setInitialVisibleRange(DISPLAY_COUNT, futureBars); + applyInitialVisibleRange(m); }; requestAnimationFrame(() => requestAnimationFrame(runRestore)); [200, 500, 1000].forEach(delay => setTimeout(runRestore, delay)); } - }, [candleOnlyMode, chartMgr, applyPaneLayout]); + }, [candleOnlyMode, chartMgr, applyPaneLayout, applyInitialVisibleRange]); // ── 전체 재로드 (데이터 + 인디케이터) ───────────────────────────────────── const reloadSafetyTimers = useRef[]>([]); @@ -778,11 +810,10 @@ const TradingChart: React.FC = ({ requestAnimationFrame(() => { applyPaneLayout(mgr); requestAnimationFrame(() => { - const futureBars = mgr.hasIchimoku() ? 28 : 0; - mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); + applyInitialVisibleRange(mgr); }); }); - }, [applyPaneLayout]); + }, [applyPaneLayout, applyInitialVisibleRange]); const reloadAll = useCallback(async ( mgr: ChartManager, @@ -887,6 +918,7 @@ const TradingChart: React.FC = ({ queueIndicatorRecovery(); return; } + if (skipViewportRecoveryRef.current) return; const w = wrapperRef.current; if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return; normalizeChartViewport(m); @@ -1233,8 +1265,7 @@ const TradingChart: React.FC = ({ if (savedLR) { m.applyVisibleLogicalRange(savedLR.from, savedLR.to); } else { - const fb = m.hasIchimoku() ? 28 : 0; - m.setInitialVisibleRange(DISPLAY_COUNT, fb); + applyInitialVisibleRange(m); } }); } @@ -1528,10 +1559,11 @@ const TradingChart: React.FC = ({ const layoutRecoveryAttemptedRef = useRef(false); useEffect(() => { layoutRecoveryAttemptedRef.current = false; + lastViewportFitRef.current = null; }, [market, timeframe]); useEffect(() => { - if (!chartVisible) return; + if (!chartVisible || skipViewportRecovery) return; const mgr = managerRef.current; if (!mgr || !chartMgr || bars.length < 2) return; if (!canApplyChartBars(bars, barsMarket, market)) return; @@ -1561,7 +1593,7 @@ const TradingChart: React.FC = ({ ); return () => { timers.forEach(clearTimeout); }; - }, [chartMgr, bars.length, barsMarket, market, chartVisible, normalizeChartViewport]); + }, [chartMgr, bars.length, barsMarket, market, chartVisible, normalizeChartViewport, skipViewportRecovery]); // 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후) useEffect(() => { diff --git a/frontend/src/components/virtual/VirtualTargetCardChart.tsx b/frontend/src/components/virtual/VirtualTargetCardChart.tsx index c50214c..6992fcf 100644 --- a/frontend/src/components/virtual/VirtualTargetCardChart.tsx +++ b/frontend/src/components/virtual/VirtualTargetCardChart.tsx @@ -22,7 +22,15 @@ import { resolveStrategyTimeframes, } from '../../utils/strategyToChartIndicators'; import { timeframeToCandleType } from '../../utils/chartCandleType'; -import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; +import { DISPLAY_COUNT } from '../../hooks/useUpbitData'; + +/** 미니 카드 폭에 맞춰 표시할 캔들 수 (좌우 빈 공간·x축 라벨 겹침 방지) */ +const COMPACT_MIN_BAR_PX = 5; + +function resolveCompactDisplayCount(width: number): number { + if (width <= 0) return DISPLAY_COUNT; + return Math.max(40, Math.min(DISPLAY_COUNT, Math.floor(width / COMPACT_MIN_BAR_PX))); +} interface Props { market: string; @@ -81,6 +89,11 @@ const VirtualTargetCardChart: React.FC = ({ [indicators, timeframe], ); + const resolveInitialDisplayCount = useCallback( + (plotWidth: number) => resolveCompactDisplayCount(plotWidth), + [], + ); + const managerRef = useRef(null); const canvasWrapRef = useRef(null); const [chartReloadTick, setChartReloadTick] = useState(0); @@ -106,9 +119,10 @@ const VirtualTargetCardChart: React.FC = ({ chartLiveReadyRef.current = true; const pending = pendingBarRef.current; const mgr = managerRef.current; - if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return; - pendingBarRef.current = null; - mgr.updateBar(pending); + if (pending && mgr && barsMarketRef.current === marketRef.current) { + pendingBarRef.current = null; + mgr.updateBar(pending); + } syncChartLayout(); }, [syncChartLayout]); @@ -155,7 +169,6 @@ const VirtualTargetCardChart: React.FC = ({ const barsReady = bars.length >= 2 && barsMarket === market; - const requestChartReload = useCallback(() => { chartReloadTriggeredRef.current = true; setChartReloadTick(t => t + 1); @@ -171,36 +184,39 @@ const VirtualTargetCardChart: React.FC = ({ const wrap = canvasWrapRef.current; if (!wrap) return; - const timers = [200, 600, 1200].map(delay => + let resizeTimer: ReturnType | null = null; + const scheduleLayoutSync = () => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => syncChartLayout(), 120); + }; + + const timers = [600].map(delay => setTimeout(() => { if (!canvasWrapRef.current) return; if (managerRef.current?.hasMainSeries()) { syncChartLayout(); - } else if (!chartReloadTriggeredRef.current && delay >= 600 && barsReady) { + } else if (!chartReloadTriggeredRef.current && barsReady) { requestChartReload(); } }, delay), ); - const ro = new ResizeObserver(() => syncChartLayout()); + const ro = new ResizeObserver(() => scheduleLayoutSync()); ro.observe(wrap); return () => { timers.forEach(clearTimeout); + if (resizeTimer) clearTimeout(resizeTimer); ro.disconnect(); }; }, [market, timeframe, barsReady, syncChartLayout, requestChartReload]); useEffect(() => { if (!barsReady) return; - if (managerRef.current?.hasMainSeries()) { - syncChartLayout(); - return; - } - if (!chartReloadTriggeredRef.current) { + if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) { requestChartReload(); } - }, [barsReady, syncChartLayout, requestChartReload]); + }, [barsReady, requestChartReload]); useEffect(() => { if (!streamActive) return; @@ -242,7 +258,7 @@ const VirtualTargetCardChart: React.FC = ({
캔들 데이터 수집 중…
)} i.id).join(',')}`} chartVisible={chartStreamEnabled} bars={bars} barsMarket={barsMarket} @@ -261,6 +277,8 @@ const VirtualTargetCardChart: React.FC = ({ onManagerReady={mgr => { managerRef.current = mgr; }} onAddDrawing={noop as (d: Drawing) => void} onCandlesReady={handleCandlesReady} + resolveInitialDisplayCount={resolveInitialDisplayCount} + skipViewportRecovery magnifierEnabled={false} volumeVisible={false} paneLayoutClamp