From 961609242cdf4a512ead7a627bd3add586900358 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sat, 30 May 2026 03:35:34 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B0=A8=ED=8A=B8=EB=A1=9C=EB=94=A9=EC=A4=91?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 10 ++++ frontend/src/App.tsx | 21 +++---- frontend/src/components/TradingChart.tsx | 56 +++++++++++-------- frontend/src/styles/tradeNotificationList.css | 22 ++++---- frontend/src/utils/ChartManager.ts | 7 ++- frontend/src/utils/dataGenerator.ts | 14 ++--- 6 files changed, 70 insertions(+), 60 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 36579d8..b9d6a6f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -947,6 +947,10 @@ html.theme-blue { .tv-chart-wrap--candle-only { overflow-y: hidden; } +/* 초기 로드 중 LWC pane·구분선이 순차 노출되는 것 방지 */ +.tv-chart-wrap--bootstrapping .chart-container { + visibility: hidden; +} .tv-chart-wrap::-webkit-scrollbar { width: 6px; } .tv-chart-wrap::-webkit-scrollbar-track { background: transparent; } .tv-chart-wrap::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; } @@ -1142,6 +1146,12 @@ html.theme-blue { color: var(--text2); font-size: 13px; } +/* 실시간 차트 영역 중앙 단일 로딩 (tv-chart-wrap 내부) */ +.tv-chart-wrap .chart-loading--chart-area { + z-index: 920; + background: var(--bg, #131722); + pointer-events: none; +} .loading-spinner { width: 32px; height: 32px; border: 3px solid var(--bg4); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2863567..ae0de4c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2274,23 +2274,10 @@ function App() { legend={legend} /> - {/* 로딩 오버레이 */} - {useUpbit && isLoading && ( -
-
- 업비트 데이터 로딩 중... -
- )} - {isSingleLoadingMore && ( -
-
- 과거 데이터 로딩... -
- )} - + {isSingleLoadingMore && ( +
+
+ 과거 데이터 로딩... +
+ )} {/* 백테스팅 결과 통계 배지 */} {btMatchesChart && layoutDef.count === 1 && ( diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 71e641f..6595f1a 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -59,6 +59,7 @@ import CandlePaneControls from './CandlePaneControls'; import { getKoreanName } from '../utils/marketNameCache'; import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync'; +import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge'; import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; import PaneSeparatorOverlay from './PaneSeparatorOverlay'; @@ -148,6 +149,8 @@ interface TradingChartProps { showPaneLegend?: boolean; /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ crosshairInfoVisible?: boolean; + /** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */ + dataLoading?: boolean; } const TradingChart: React.FC = ({ @@ -184,6 +187,7 @@ const TradingChart: React.FC = ({ paneLayoutClamp = false, showPaneLegend = true, crosshairInfoVisible = true, + dataLoading = false, }) => { const containerRef = useRef(null); const wrapperRef = useRef(null); // 스크롤 래퍼 @@ -252,6 +256,8 @@ const TradingChart: React.FC = ({ const chartTimeFormat = useChartTimeFormat(); const [chartMgr, setChartMgr] = useState(null); + /** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */ + const [chartPaintReady, setChartPaintReady] = useState(false); /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */ const [candleOnlyMode, setCandleOnlyMode] = useState(false); const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null); @@ -637,6 +643,7 @@ const TradingChart: React.FC = ({ barsRef.current = newBars; reloadInFlightRef.current = true; + setChartPaintReady(false); try { do { @@ -657,21 +664,12 @@ const TradingChart: React.FC = ({ mgr.setTheme(th); mgr.setLogScale(ls); mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat); - commitCandleLayout(mgr); - onCandlesReady?.(); lastAppliedMarketRef.current = market; - let stale = false; - for (const ind of inds) { - if (gen !== reloadGenRef.current) { - prevBarsKey.current = ''; - stale = true; - break; - } - await mgr.addIndicator(ind); + const sortedInds = sortIndicatorsForPaneLoad(inds); + if (sortedInds.length > 0) { + await mgr.addIndicatorsBatch(sortedInds); } - if (stale) break; - if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; break; @@ -688,7 +686,6 @@ const TradingChart: React.FC = ({ if (gen !== reloadGenRef.current) break; } if (mgr.getLoadedIndicatorCount() < expected) { - commitCandleLayout(mgr); if (reloadRetryRef.current < 6) { reloadRetryRef.current += 1; queueIndicatorRecovery(); @@ -706,10 +703,10 @@ const TradingChart: React.FC = ({ lastAppliedMarketRef.current = market; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { - applyPaneLayout(mgr); + normalizeChartViewport(mgr); requestAnimationFrame(() => { - const futureBars = mgr.hasIchimoku() ? 28 : 0; - mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); + onCandlesReady?.(); + setChartPaintReady(true); resolve(); }); }))); @@ -740,12 +737,15 @@ const TradingChart: React.FC = ({ } while (reloadCoalesceRef.current); } finally { reloadInFlightRef.current = false; + if (managerRef.current?.hasMainSeries()) { + setChartPaintReady(true); + } if (reloadCoalesceRef.current) { reloadCoalesceRef.current = false; queueFullReload(); } } - }, [applyPaneLayout, commitCandleLayout, normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); + }, [normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); useEffect(() => { reloadAllRef.current = reloadAll; @@ -1022,7 +1022,7 @@ const TradingChart: React.FC = ({ resizeDebounceRef.current = window.setTimeout(() => { resizeDebounceRef.current = null; const m = managerRef.current; - if (!m) return; + if (!m || reloadInFlightRef.current) return; // 컨테이너가 처음으로 유효한 크기를 가질 때: // 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드 @@ -1123,6 +1123,7 @@ const TradingChart: React.FC = ({ reloadCoalesceRef.current = false; reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; + setChartPaintReady(false); managerRef.current?.clearForSymbolChange(); managerRef.current?.cancelPendingIndicatorUpdates(); }, [market, timeframe]); @@ -1338,8 +1339,9 @@ const TradingChart: React.FC = ({ if (!canApplyChartBars(bars, barsMarket, market)) return; if (!mgr.hasMainSeries()) return; - const timers = [0, 150, 400, 800, 1500, 2500, 4000].map(delay => + const timers = [600, 1200, 2500, 4000].map(delay => window.setTimeout(() => { + if (reloadInFlightRef.current) return; const m = managerRef.current; if (!m?.hasMainSeries()) return; const w = wrapperRef.current; @@ -1498,20 +1500,28 @@ const TradingChart: React.FC = ({ setCtxMenu(null); }, [ctxMenu, market]); + const showChartLoading = !chartPaintReady || dataLoading; + return ( // wrapperRef: 스크롤 영역 (overflow-y: auto → App.css .tv-chart-wrap)
+ {showChartLoading && ( +
+
+ 차트 로딩중... +
+ )}
- {chartMgr && !candleOnlyMode && showPaneLegend && ( + {chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && ( = ({ getContainer={() => containerRef.current} /> )} - {chartMgr && paneSeparatorOptions && ( + {chartMgr && chartPaintReady && paneSeparatorOptions && ( = ({ onRestore={() => setCandleOnlyMode(false)} /> )} - {chartMgr && !candleOnlyMode && showPaneLegend && ( + {chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && ( = 1_000) { - return price.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); - } - if (abs >= 1) { - return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - } - return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 }); + return price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); } export function formatTime(ts: number, timeframe: Timeframe): string {