diff --git a/frontend/src/App.css b/frontend/src/App.css index e39acd0..36579d8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -843,6 +843,7 @@ html.theme-blue { overflow: hidden; border-right: 1px solid var(--border); min-width: 0; + min-height: 0; } /* ── Chart Legend ───────────────────────────────────────────────── */ diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 48b289e..71e641f 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -9,6 +9,10 @@ import { DISPLAY_COUNT } from '../hooks/useUpbitData'; /** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */ const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4)); +/** pane 비율·가격축 계산에 쓸 수 있는 최소 래퍼 높이 (원격·모바일 레이아웃 지연 대응) */ +const MIN_VALID_LAYOUT_HEIGHT = 80; +const LAYOUT_RETRY_MAX = 30; + function createIndicatorReloadCover(containerEl: HTMLDivElement | null): HTMLDivElement | null { if (!containerEl?.parentElement) return null; const cover = document.createElement('div'); @@ -331,7 +335,7 @@ const TradingChart: React.FC = ({ * * wrapper.clientHeight 를 정확한 가용 높이로 ChartManager 에 전달합니다. * CSS Grid 레이아웃 초기화 지연으로 wrapperH=0 이 될 수 있으므로, - * retryCount 를 통해 최대 6회 자동 재시도합니다 (100→200→300ms…) + * retryCount 를 통해 최대 30회 자동 재시도합니다 (원격·테더링 등 느린 레이아웃 대응). */ const applyPaneLayout = useCallback((mgr: ChartManager, retryCount = 0) => { const wrapper = wrapperRef.current; @@ -339,10 +343,10 @@ const TradingChart: React.FC = ({ if (!wrapper || !container) return; const wrapperH = wrapper.clientHeight; - if (wrapperH <= 0) { - // CSS Grid 높이가 아직 계산되지 않은 경우: 지연 후 재시도 (최대 6회) - if (retryCount < 6) { - setTimeout(() => applyPaneLayout(mgr, retryCount + 1), 100 * (retryCount + 1)); + if (wrapperH < MIN_VALID_LAYOUT_HEIGHT) { + if (retryCount < LAYOUT_RETRY_MAX) { + const delay = Math.min(120 * (retryCount + 1), 500); + setTimeout(() => applyPaneLayout(mgr, retryCount + 1), delay); } return; } @@ -372,10 +376,23 @@ const TradingChart: React.FC = ({ container.style.height = `${required}px`; } else { container.style.height = ''; + try { + if (w > 0 && wrapperH > 0) mgr.resize(w, wrapperH); + } catch { /* ok */ } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [paneLayoutClamp]); + /** 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); + }); + }, [applyPaneLayout]); + /** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */ useEffect(() => { const mgr = managerRef.current; @@ -704,7 +721,7 @@ const TradingChart: React.FC = ({ onDataLoaded?.(); - reloadSafetyTimers.current = [300, 700, 1400].map(delay => + reloadSafetyTimers.current = [300, 700, 1400, 2500].map(delay => setTimeout(() => { const m = managerRef.current; if (!m || !m.hasMainSeries()) return; @@ -715,11 +732,9 @@ const TradingChart: React.FC = ({ return; } const w = wrapperRef.current; - if (!w || w.clientHeight <= 0) return; - m.resetPaneHeights(w.clientHeight); - const fb = m.hasIchimoku() ? 28 : 0; - m.setInitialVisibleRange(DISPLAY_COUNT, fb); - if (delay === 1400) onDataLoaded?.(); + if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return; + normalizeChartViewport(m); + if (delay === 2500) onDataLoaded?.(); }, delay) ); } while (reloadCoalesceRef.current); @@ -730,7 +745,7 @@ const TradingChart: React.FC = ({ queueFullReload(); } } - }, [applyPaneLayout, commitCandleLayout, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); + }, [applyPaneLayout, commitCandleLayout, normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); useEffect(() => { reloadAllRef.current = reloadAll; @@ -977,8 +992,13 @@ const TradingChart: React.FC = ({ // autoSize:true 를 쓰면 LWC 가 내부 ResizeObserver 로 자동 크기 조절. // 이 외부 ResizeObserver 는 applyPaneLayout 재트리거 + 데이터 재로드 보장용. let prevW = 0, prevH = 0; - const ro = new ResizeObserver(([entry]) => { - const { width, height } = entry.contentRect; + const ro = new ResizeObserver((entries) => { + let width = 0; + let height = 0; + for (const e of entries) { + width = Math.max(width, e.contentRect.width); + height = Math.max(height, e.contentRect.height); + } if (width === prevW && height === prevH) return; // 이전 크기가 0이었다가 유효해진 경우 (= display:none → visible 전환) const wasHidden = prevW <= 0 || prevH <= 0; @@ -1035,7 +1055,9 @@ const TradingChart: React.FC = ({ } }, 80); }); - ro.observe(containerRef.current); + const wrapperEl = wrapperRef.current; + if (wrapperEl) ro.observe(wrapperEl); + ro.observe(container); const bootstrapIfReady = () => { const m = managerRef.current; @@ -1303,6 +1325,44 @@ const TradingChart: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators, flushPendingIndicatorResync]); + // 원격·모바일 등 레이아웃·데이터 로드 지연 후 pane·가격축 안정화 + const layoutRecoveryAttemptedRef = useRef(false); + useEffect(() => { + layoutRecoveryAttemptedRef.current = false; + }, [market, timeframe]); + + useEffect(() => { + if (!chartVisible) return; + const mgr = managerRef.current; + if (!mgr || !chartMgr || bars.length < 2) return; + if (!canApplyChartBars(bars, barsMarket, market)) return; + if (!mgr.hasMainSeries()) return; + + const timers = [0, 150, 400, 800, 1500, 2500, 4000].map(delay => + window.setTimeout(() => { + const m = managerRef.current; + if (!m?.hasMainSeries()) return; + const w = wrapperRef.current; + if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return; + normalizeChartViewport(m); + if (delay >= 2500 && m.isMainPriceScaleLikelyCorrupt() && !layoutRecoveryAttemptedRef.current) { + layoutRecoveryAttemptedRef.current = true; + prevBarsKey.current = ''; + void reloadAllRef.current( + m, + barsRef.current, + prevChartType.current, + prevTheme.current, + prevLogScale.current, + indicatorsRef.current, + ); + } + }, delay), + ); + + return () => { timers.forEach(clearTimeout); }; + }, [chartMgr, bars.length, barsMarket, market, chartVisible, normalizeChartViewport]); + // 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후) useEffect(() => { if (!chartVisible) return; @@ -1311,7 +1371,7 @@ const TradingChart: React.FC = ({ if (!canApplyChartBars(bars, barsMarket, market)) return; if (mgr.hasMainSeries()) return; - const timers = [0, 80, 200, 500].map(delay => + const timers = [0, 80, 200, 500, 1200, 2500].map(delay => window.setTimeout(() => { const m = managerRef.current; if (!m || m.hasMainSeries()) return; diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 6e0a750..9b8fa9e 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -3435,6 +3435,32 @@ export class ChartManager { this.mainSeries?.priceScale().setAutoScale(true); } + /** + * 느린 네트워크·레이아웃 지연 후 가격축이 0~수억처럼 비정상인지 감지. + * (마지막 종가가 가시 범위 밖이거나, 범위 하한이 0 이하인 경우) + */ + isMainPriceScaleLikelyCorrupt(): boolean { + if (!this.mainSeries || this.rawBars.length < 2) return false; + const lastClose = this.rawBars[this.rawBars.length - 1].close; + if (!Number.isFinite(lastClose) || lastClose <= 0) return false; + + const main = this.getPaneLayouts().find(l => l.paneIndex === 0); + if (!main || main.height < 24) return true; + + try { + const topPrice = this.mainSeries.coordinateToPrice(4); + const bottomPrice = this.mainSeries.coordinateToPrice(main.height - 4); + if (topPrice == null || bottomPrice == null) return false; + const lo = Math.min(topPrice, bottomPrice); + const hi = Math.max(topPrice, bottomPrice); + if (lo <= 0) return true; + if (lastClose < lo * 0.5 || lastClose > hi * 2) return true; + } catch { + return false; + } + return false; + } + /** * 크로스헤어 파라미터에서 보조지표 시리즈 값 추출 * { indicatorType → [plot0 값, plot1 값, ...] }