차트로딩중 적용

This commit is contained in:
Macbook
2026-05-30 03:35:34 +09:00
parent e7a6c22cc2
commit 961609242c
6 changed files with 70 additions and 60 deletions
+10
View File
@@ -947,6 +947,10 @@ html.theme-blue {
.tv-chart-wrap--candle-only { .tv-chart-wrap--candle-only {
overflow-y: hidden; 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 { width: 6px; }
.tv-chart-wrap::-webkit-scrollbar-track { background: transparent; } .tv-chart-wrap::-webkit-scrollbar-track { background: transparent; }
.tv-chart-wrap::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; } .tv-chart-wrap::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
@@ -1142,6 +1146,12 @@ html.theme-blue {
color: var(--text2); color: var(--text2);
font-size: 13px; 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 { .loading-spinner {
width: 32px; height: 32px; width: 32px; height: 32px;
border: 3px solid var(--bg4); border: 3px solid var(--bg4);
+7 -14
View File
@@ -2274,23 +2274,10 @@ function App() {
legend={legend} legend={legend}
/> />
{/* 로딩 오버레이 */}
{useUpbit && isLoading && (
<div className="chart-loading">
<div className="loading-spinner" />
<span> ...</span>
</div>
)}
{isSingleLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> ...</span>
</div>
)}
<TradingChart <TradingChart
key={chartMountKey} key={chartMountKey}
chartVisible={chartVisible} chartVisible={chartVisible}
dataLoading={useUpbit && isLoading}
bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone} bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
chartType={chartType} theme={theme} mode={mode} chartType={chartType} theme={theme} mode={mode}
indicators={effectiveIndicators} drawingTool={drawingTool} indicators={effectiveIndicators} drawingTool={drawingTool}
@@ -2362,6 +2349,12 @@ function App() {
volumeVisible={chartVolumeVisible} volumeVisible={chartVolumeVisible}
paneSeparatorOptions={chartPaneSeparator} paneSeparatorOptions={chartPaneSeparator}
/> />
{isSingleLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> ...</span>
</div>
)}
{/* 백테스팅 결과 통계 배지 */} {/* 백테스팅 결과 통계 배지 */}
{btMatchesChart && layoutDef.count === 1 && ( {btMatchesChart && layoutDef.count === 1 && (
+33 -23
View File
@@ -59,6 +59,7 @@ import CandlePaneControls from './CandlePaneControls';
import { getKoreanName } from '../utils/marketNameCache'; import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync'; import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import PaneSeparatorOverlay from './PaneSeparatorOverlay'; import PaneSeparatorOverlay from './PaneSeparatorOverlay';
@@ -148,6 +149,8 @@ interface TradingChartProps {
showPaneLegend?: boolean; showPaneLegend?: boolean;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
crosshairInfoVisible?: boolean; crosshairInfoVisible?: boolean;
/** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */
dataLoading?: boolean;
} }
const TradingChart: React.FC<TradingChartProps> = ({ const TradingChart: React.FC<TradingChartProps> = ({
@@ -184,6 +187,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
paneLayoutClamp = false, paneLayoutClamp = false,
showPaneLegend = true, showPaneLegend = true,
crosshairInfoVisible = true, crosshairInfoVisible = true,
dataLoading = false,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼 const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
@@ -252,6 +256,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
const chartTimeFormat = useChartTimeFormat(); const chartTimeFormat = useChartTimeFormat();
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null); const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
/** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
const [chartPaintReady, setChartPaintReady] = useState(false);
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */ /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
const [candleOnlyMode, setCandleOnlyMode] = useState(false); const [candleOnlyMode, setCandleOnlyMode] = useState(false);
const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null); const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null);
@@ -637,6 +643,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
barsRef.current = newBars; barsRef.current = newBars;
reloadInFlightRef.current = true; reloadInFlightRef.current = true;
setChartPaintReady(false);
try { try {
do { do {
@@ -657,21 +664,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
mgr.setTheme(th); mgr.setTheme(th);
mgr.setLogScale(ls); mgr.setLogScale(ls);
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat); mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
commitCandleLayout(mgr);
onCandlesReady?.();
lastAppliedMarketRef.current = market; lastAppliedMarketRef.current = market;
let stale = false; const sortedInds = sortIndicatorsForPaneLoad(inds);
for (const ind of inds) { if (sortedInds.length > 0) {
if (gen !== reloadGenRef.current) { await mgr.addIndicatorsBatch(sortedInds);
prevBarsKey.current = '';
stale = true;
break;
}
await mgr.addIndicator(ind);
} }
if (stale) break;
if (gen !== reloadGenRef.current) { if (gen !== reloadGenRef.current) {
prevBarsKey.current = ''; prevBarsKey.current = '';
break; break;
@@ -688,7 +686,6 @@ const TradingChart: React.FC<TradingChartProps> = ({
if (gen !== reloadGenRef.current) break; if (gen !== reloadGenRef.current) break;
} }
if (mgr.getLoadedIndicatorCount() < expected) { if (mgr.getLoadedIndicatorCount() < expected) {
commitCandleLayout(mgr);
if (reloadRetryRef.current < 6) { if (reloadRetryRef.current < 6) {
reloadRetryRef.current += 1; reloadRetryRef.current += 1;
queueIndicatorRecovery(); queueIndicatorRecovery();
@@ -706,10 +703,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
lastAppliedMarketRef.current = market; lastAppliedMarketRef.current = market;
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
applyPaneLayout(mgr); normalizeChartViewport(mgr);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const futureBars = mgr.hasIchimoku() ? 28 : 0; onCandlesReady?.();
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); setChartPaintReady(true);
resolve(); resolve();
}); });
}))); })));
@@ -740,12 +737,15 @@ const TradingChart: React.FC<TradingChartProps> = ({
} while (reloadCoalesceRef.current); } while (reloadCoalesceRef.current);
} finally { } finally {
reloadInFlightRef.current = false; reloadInFlightRef.current = false;
if (managerRef.current?.hasMainSeries()) {
setChartPaintReady(true);
}
if (reloadCoalesceRef.current) { if (reloadCoalesceRef.current) {
reloadCoalesceRef.current = false; reloadCoalesceRef.current = false;
queueFullReload(); queueFullReload();
} }
} }
}, [applyPaneLayout, commitCandleLayout, normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); }, [normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]);
useEffect(() => { useEffect(() => {
reloadAllRef.current = reloadAll; reloadAllRef.current = reloadAll;
@@ -1022,7 +1022,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
resizeDebounceRef.current = window.setTimeout(() => { resizeDebounceRef.current = window.setTimeout(() => {
resizeDebounceRef.current = null; resizeDebounceRef.current = null;
const m = managerRef.current; const m = managerRef.current;
if (!m) return; if (!m || reloadInFlightRef.current) return;
// 컨테이너가 처음으로 유효한 크기를 가질 때: // 컨테이너가 처음으로 유효한 크기를 가질 때:
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드 // 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
@@ -1123,6 +1123,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
reloadCoalesceRef.current = false; reloadCoalesceRef.current = false;
reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = []; reloadSafetyTimers.current = [];
setChartPaintReady(false);
managerRef.current?.clearForSymbolChange(); managerRef.current?.clearForSymbolChange();
managerRef.current?.cancelPendingIndicatorUpdates(); managerRef.current?.cancelPendingIndicatorUpdates();
}, [market, timeframe]); }, [market, timeframe]);
@@ -1338,8 +1339,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
if (!canApplyChartBars(bars, barsMarket, market)) return; if (!canApplyChartBars(bars, barsMarket, market)) return;
if (!mgr.hasMainSeries()) 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(() => { window.setTimeout(() => {
if (reloadInFlightRef.current) return;
const m = managerRef.current; const m = managerRef.current;
if (!m?.hasMainSeries()) return; if (!m?.hasMainSeries()) return;
const w = wrapperRef.current; const w = wrapperRef.current;
@@ -1498,20 +1500,28 @@ const TradingChart: React.FC<TradingChartProps> = ({
setCtxMenu(null); setCtxMenu(null);
}, [ctxMenu, market]); }, [ctxMenu, market]);
const showChartLoading = !chartPaintReady || dataLoading;
return ( return (
// wrapperRef: 스크롤 영역 (overflow-y: auto → App.css .tv-chart-wrap) // wrapperRef: 스크롤 영역 (overflow-y: auto → App.css .tv-chart-wrap)
<div <div
ref={wrapperRef} ref={wrapperRef}
className={`tv-chart-wrap${candleOnlyMode ? ' tv-chart-wrap--candle-only' : ''}`} className={`tv-chart-wrap${candleOnlyMode ? ' tv-chart-wrap--candle-only' : ''}${chartPaintReady ? '' : ' tv-chart-wrap--bootstrapping'}`}
style={{ flex: 1, position: 'relative', minHeight: 0 }} style={{ flex: 1, position: 'relative', minHeight: 0 }}
onPointerMove={handleWrapperMouseMove} onPointerMove={handleWrapperMouseMove}
> >
{showChartLoading && (
<div className="chart-loading chart-loading--chart-area" role="status" aria-live="polite">
<div className="loading-spinner" />
<span> ...</span>
</div>
)}
<div className="chart-main-row"> <div className="chart-main-row">
<div <div
ref={containerRef} ref={containerRef}
className="chart-container" className="chart-container"
/> />
{chartMgr && !candleOnlyMode && showPaneLegend && ( {chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && (
<ChartRightToolbar <ChartRightToolbar
manager={chartMgr} manager={chartMgr}
indicators={indicators} indicators={indicators}
@@ -1546,7 +1556,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
getContainer={() => containerRef.current} getContainer={() => containerRef.current}
/> />
)} )}
{chartMgr && paneSeparatorOptions && ( {chartMgr && chartPaintReady && paneSeparatorOptions && (
<PaneSeparatorOverlay <PaneSeparatorOverlay
manager={chartMgr} manager={chartMgr}
options={paneSeparatorOptions} options={paneSeparatorOptions}
@@ -1576,7 +1586,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
onRestore={() => setCandleOnlyMode(false)} onRestore={() => setCandleOnlyMode(false)}
/> />
)} )}
{chartMgr && !candleOnlyMode && showPaneLegend && ( {chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && (
<PaneLegendPortal <PaneLegendPortal
key={indKey(indicators)} key={indKey(indicators)}
manager={chartMgr} manager={chartMgr}
+11 -11
View File
@@ -56,9 +56,9 @@
/* 목록 한 줄 = 알림상세 · 신호일치율 · 캔들 · 보조지표(1칸) — 가로 100% 꽉 참 */ /* 목록 한 줄 = 알림상세 · 신호일치율 · 캔들 · 보조지표(1칸) — 가로 100% 꽉 참 */
.tnl-row-gallery { .tnl-row-gallery {
--tnl-summary-card-width: 320px; --tnl-summary-card-width: 288px;
--tnl-signal-slot-width: 300px; --tnl-signal-slot-width: 308px;
--tnl-chart-card-width: 280px; --tnl-chart-card-width: 294px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
@@ -71,17 +71,17 @@
} }
.tnl-row-gallery-detail { .tnl-row-gallery-detail {
flex: 1.15 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
} }
.tnl-row-gallery-signal { .tnl-row-gallery-signal {
flex: 1 1 0; flex: 1.05 1 0;
min-width: 0; min-width: 0;
} }
.tnl-row-gallery-candle { .tnl-row-gallery-candle {
flex: 1 1 0; flex: 1.05 1 0;
min-width: 0; min-width: 0;
height: 300px; height: 300px;
max-height: 300px; max-height: 300px;
@@ -107,7 +107,7 @@
} }
.tnl-row-gallery-indicators { .tnl-row-gallery-indicators {
flex: 1 1 0; flex: 1.05 1 0;
min-width: 0; min-width: 0;
} }
@@ -123,9 +123,9 @@
} }
.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail { .tnl-row-gallery--chart-expanded .tnl-row-gallery-detail {
flex: 0 0 min(28%, var(--tnl-summary-card-width)); flex: 0 0 min(26%, var(--tnl-summary-card-width));
width: min(28%, var(--tnl-summary-card-width)); width: min(26%, var(--tnl-summary-card-width));
min-width: min(240px, var(--tnl-summary-card-width)); min-width: min(220px, var(--tnl-summary-card-width));
max-width: var(--tnl-summary-card-width); max-width: var(--tnl-summary-card-width);
} }
@@ -1213,7 +1213,7 @@ ul.tnl-list.tnl-list--grid {
} }
.tnl-row-gallery { .tnl-row-gallery {
--tnl-summary-card-width: min(100%, 320px); --tnl-summary-card-width: min(100%, 288px);
} }
.tnl-row-gallery-detail, .tnl-row-gallery-detail,
+4 -3
View File
@@ -869,11 +869,12 @@ export class ChartManager {
this._reapplyAllPatternMarkers(); this._reapplyAllPatternMarkers();
this._removeExtraSubPanes(); this._removeExtraSubPanes();
// ② 새 순서로 재추가 (병합 호스트 → 멤버 순) // ② 새 순서로 재추가 (병합 호스트 → 멤버 순) — 레이아웃은 마지막에 한 번만
this._indRunning = false; this._indRunning = false;
for (const ind of sortIndicatorsForPaneLoad(inds)) { const sorted = sortIndicatorsForPaneLoad(inds);
for (const ind of sorted) {
if (this._indicatorLoadStale(loadGen)) break; if (this._indicatorLoadStale(loadGen)) break;
await this.addIndicator(ind); await this.addIndicator(ind, { skipLayout: true });
} }
if (this._indicatorLoadStale(loadGen)) return; if (this._indicatorLoadStale(loadGen)) return;
+5 -9
View File
@@ -97,17 +97,13 @@ export function formatChartAxisPrice(price: number): string {
}); });
} }
/** 보조지표 pane 우측 가격축·시리즈 라벨 (소수점 표시) */ /** 보조지표 pane 우측 가격축·시리즈 라벨 (소수점 2자리 고정) */
export function formatIndicatorAxisPrice(price: number): string { export function formatIndicatorAxisPrice(price: number): string {
if (!Number.isFinite(price)) return String(price); if (!Number.isFinite(price)) return String(price);
const abs = Math.abs(price); return price.toLocaleString('en-US', {
if (abs >= 1_000) { minimumFractionDigits: 2,
return price.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); maximumFractionDigits: 2,
} });
if (abs >= 1) {
return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
} }
export function formatTime(ts: number, timeframe: Timeframe): string { export function formatTime(ts: number, timeframe: Timeframe): string {