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 {