실시간 차트 갱신시 화면떨림 문제 해결

This commit is contained in:
Macbook
2026-05-27 14:54:50 +09:00
parent f7aac535d8
commit 7a0af36b9b
10 changed files with 517 additions and 130 deletions
+228 -92
View File
@@ -8,6 +8,27 @@ import { DISPLAY_COUNT } from '../hooks/useUpbitData';
/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */
const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4));
function createIndicatorReloadCover(containerEl: HTMLDivElement | null): HTMLDivElement | null {
if (!containerEl) return null;
const cover = document.createElement('div');
cover.style.cssText = [
'position:absolute', 'inset:0', 'z-index:900',
'pointer-events:none', 'opacity:1',
'background:var(--bg,#131722)',
].join(';');
containerEl.parentElement?.appendChild(cover);
return cover;
}
function fadeOutIndicatorReloadCover(cover: HTMLDivElement | null): void {
if (!cover) return;
requestAnimationFrame(() => requestAnimationFrame(() => {
cover.style.transition = 'opacity 0.25s ease';
cover.style.opacity = '0';
setTimeout(() => { cover.remove(); }, 300);
}));
}
/** 종목 전환 직후 stale/partial 데이터 차단 — barsMarket 확정 후 렌더 허용 */
function canApplyChartBars(
barData: OHLCVBar[],
@@ -29,6 +50,7 @@ import ChartContextMenu from './ChartContextMenu';
import CandlePaneControls from './CandlePaneControls';
import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
import { classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
interface TradingChartProps {
bars: OHLCVBar[];
@@ -102,6 +124,8 @@ interface TradingChartProps {
showHoverToolbar?: boolean;
/** 보조지표 우측 가격축 라벨·설명 (차트 설정, 기본 true) */
seriesPriceLabelsEnabled?: boolean;
/** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */
chartVisible?: boolean;
}
const TradingChart: React.FC<TradingChartProps> = ({
@@ -131,6 +155,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
volumeVisible = true,
showHoverToolbar = true,
seriesPriceLabelsEnabled = true,
chartVisible = true,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
@@ -180,6 +205,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevBarsKey = useRef<string>('');
const prevIndKey = useRef<string>('');
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
const indicatorSyncInFlightRef = useRef(false);
const prevMarket = useRef<string>(market);
const prevMarketTf = useRef<Timeframe>(timeframe);
const lastAppliedMarketRef = useRef<string>('');
@@ -188,6 +215,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevLogScale = useRef<boolean>(logScale);
const indicatorsRef = useRef(indicators);
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const chartVisibleRef = useRef(chartVisible);
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
@@ -205,6 +234,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
indicatorsRef.current = indicators;
}, [indicators]);
useEffect(() => {
chartVisibleRef.current = chartVisible;
}, [chartVisible]);
const toggleCandleOnly = useCallback(() => {
setCandleOnlyMode(v => !v);
}, []);
@@ -326,7 +359,71 @@ const TradingChart: React.FC<TradingChartProps> = ({
inds: IndicatorConfig[],
) => Promise<void>>(async () => {});
const syncIndicatorTrackingRefs = useCallback((inds: IndicatorConfig[]) => {
prevIndKey.current = indKey(inds);
prevSortedPKRef.current = sortedParamKey(inds);
prevIndicatorsListRef.current = inds;
}, []);
const restoreLogicalRange = useCallback((
mgr: ChartManager,
savedLR: { from: number; to: number } | null,
) => {
if (!savedLR) return;
requestAnimationFrame(() => {
mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
});
}, []);
const afterIndicatorPaneMutation = useCallback((mgr: ChartManager) => {
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
} else if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
}
applyPaneLayout(mgr);
}, [applyPaneLayout]);
/** 누락된 지표만 추가 (전체 reloadIndicatorsOnly 회피) */
const repairMissingIndicators = useCallback(async (
mgr: ChartManager,
inds: IndicatorConfig[],
) => {
const loadedIds = mgr.getLoadedIndicatorIds();
const missing = inds.filter(i => i.hidden !== true && !loadedIds.has(i.id));
if (missing.length === 0) return;
indicatorSyncInFlightRef.current = true;
try {
await mgr.addIndicatorsBatch(missing);
afterIndicatorPaneMutation(mgr);
} finally {
indicatorSyncInFlightRef.current = false;
}
}, [afterIndicatorPaneMutation]);
/** 지표만 재로드 — 커버 + 스크롤 위치 복원으로 깜빡임·떨림 완화 */
const reloadIndicatorsWithCover = useCallback((
mgr: ChartManager,
inds: IndicatorConfig[],
onComplete?: () => void,
) => {
const cover = createIndicatorReloadCover(containerRef.current);
const savedLR = mgr.getVisibleLogicalRange();
indicatorSyncInFlightRef.current = true;
void mgr.reloadIndicatorsOnly(inds).then(() => {
afterIndicatorPaneMutation(mgr);
requestAnimationFrame(() => requestAnimationFrame(() => {
restoreLogicalRange(mgr, savedLR);
fadeOutIndicatorReloadCover(cover);
indicatorSyncInFlightRef.current = false;
onComplete?.();
}));
});
}, [applyPaneLayout, afterIndicatorPaneMutation, restoreLogicalRange]);
const queueFullReload = useCallback(() => {
if (!chartVisibleRef.current) return;
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
recoveryTimerRef.current = window.setTimeout(() => {
recoveryTimerRef.current = null;
@@ -345,6 +442,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
}, [market, barsMarket]);
const queueIndicatorRecovery = useCallback(() => {
if (!chartVisibleRef.current) return;
if (reloadInFlightRef.current) return;
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
recoveryTimerRef.current = window.setTimeout(() => {
@@ -365,10 +463,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
const ls = prevLogScale.current;
if (m.hasMainSeries()) {
void m.reloadIndicatorsOnly(inds).then(() => {
void repairMissingIndicators(m, inds).then(() => {
if (m.getLoadedIndicatorCount() >= expected) {
prevBarsKey.current = barsKey(barData, market);
prevIndKey.current = indKey(inds);
syncIndicatorTrackingRefs(inds);
applyPaneLayout(m);
return;
}
@@ -378,7 +476,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
}
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
}, 120);
}, [market, barsMarket, applyPaneLayout]);
}, [market, barsMarket, applyPaneLayout, repairMissingIndicators, syncIndicatorTrackingRefs]);
/** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */
const commitCandleLayout = useCallback((mgr: ChartManager) => {
@@ -474,8 +572,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevTheme.current = th;
prevLogScale.current = ls;
prevBarsKey.current = barsKey(barData, market);
prevIndKey.current = indKey(inds);
prevSortedPKRef.current = sortedParamKey(inds);
syncIndicatorTrackingRefs(inds);
lastAppliedMarketRef.current = market;
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
@@ -768,33 +865,42 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevW = width; prevH = height;
if (width <= 0 || height <= 0) return;
const m = managerRef.current;
if (!m) return;
if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
resizeDebounceRef.current = window.setTimeout(() => {
resizeDebounceRef.current = null;
const m = managerRef.current;
if (!m) return;
// 컨테이너가 처음으로 유효한 크기를 가질 때:
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
if (barsRef.current.length > 0 && !m.hasMainSeries()) {
const bm = barsMarketRef.current;
if (!canApplyChartBars(barsRef.current, bm, marketRef.current)) return;
void reloadAll(
m,
barsRef.current,
prevChartType.current,
prevTheme.current,
prevLogScale.current,
indicatorsRef.current,
);
} else {
// 컨테이너가 처음으로 유효한 크기를 가질 때:
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
if (barsRef.current.length > 0 && !m.hasMainSeries()) {
const bm = barsMarketRef.current;
if (!canApplyChartBars(barsRef.current, bm, marketRef.current)) return;
void reloadAll(
m,
barsRef.current,
prevChartType.current,
prevTheme.current,
prevLogScale.current,
indicatorsRef.current,
);
return;
}
const savedLR = wasHidden && m.hasMainSeries() ? m.getVisibleLogicalRange() : null;
applyPaneLayout(m);
// 멀티→단일 레이아웃 전환처럼 숨겨졌다가 다시 표시될 때
// setInitialVisibleRange 를 재실행해 가시 범위(캔들 분포)를 정상화
// 숨김→표시 전환: 줌/스크롤 위치 유지 (setInitialVisibleRange 로 초기화하면 화면이 튐)
if (wasHidden && m.hasMainSeries()) {
requestAnimationFrame(() => {
const fb = m.hasIchimoku() ? 28 : 0;
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
if (savedLR) {
m.applyVisibleLogicalRange(savedLR.from, savedLR.to);
} else {
const fb = m.hasIchimoku() ? 28 : 0;
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
}
});
}
}
}, 80);
});
ro.observe(containerRef.current);
@@ -832,6 +938,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
ro.disconnect();
managerRef.current?.destroy();
managerRef.current = null;
@@ -851,6 +958,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevMarketTf.current = timeframe;
prevBarsKey.current = '';
prevIndKey.current = '';
prevIndicatorsListRef.current = [];
lastAppliedMarketRef.current = '';
barsRef.current = [];
reloadRetryRef.current = 0;
@@ -869,6 +977,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
if (!chartVisible) {
// 설정 등 다른 화면에서 숨김 중 — 키만 동기화해 복귀 시 불필요한 전체 재로드 방지
prevBarsKey.current = barsKey(bars, market);
syncIndicatorTrackingRefs(indicators);
prevChartType.current = chartType;
prevTheme.current = theme;
prevLogScale.current = logScale;
return;
}
// manager 준비 전·종목 미적용 시 reloadAll 강제
if (!mgr.hasMainSeries() || lastAppliedMarketRef.current !== market) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
@@ -896,91 +1014,105 @@ const TradingChart: React.FC<TradingChartProps> = ({
return;
}
if (barsChanged || ctChanged || indicatorsIncomplete) {
// 데이터·차트 형식 변경 또는 지표 누락 → 전체 재로드
if (barsChanged || ctChanged) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
} else if (indicatorsIncomplete) {
// 누락 지표만 추가 (전체 제거·재추가로 화면이 비는 문제 방지)
void repairMissingIndicators(mgr, indicators).then(() => {
syncIndicatorTrackingRefs(indicators);
prevBarsKey.current = barsKey(bars, market);
});
} else if (lsChanged) {
prevLogScale.current = logScale;
mgr.setLogScale(logScale);
} else if (indChanged) {
const prevList = prevIndicatorsListRef.current;
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
const currPK = paramKey(indicators);
const prevSK = prevIndKey.current.split('@@')[1] ?? '';
const currSK = styleKey(indicators);
const needsRecalc = prevPK !== currPK;
// 같은 지표 집합인데 순서만 바뀐 경우 (reorder-only)
const isReorderOnly = needsRecalc
&& sortedParamKey(indicators) === prevSortedPKRef.current
&& sortedParamKey(indicators) === sortedParamKey(prevList)
&& currSK === prevSK;
if (isReorderOnly) {
// ── pane 순서 변경만: 메인 차트를 건드리지 않고 지표만 재배치 ──────────
// 1) 지표 영역만 커버 (순간 숨김) → 2) 지표만 재추가 → 3) 서서히 공개
prevSortedPKRef.current = sortedParamKey(indicators);
prevIndKey.current = ik;
const prevMap = new Map(prevList.map(i => [i.id, i]));
const removedIds = prevList
.filter(p => !indicators.some(c => c.id === p.id))
.map(p => p.id);
const addedConfigs = indicators.filter(c => !prevMap.has(c.id));
const paramsChangedConfigs = indicators.filter(c => {
const p = prevMap.get(c.id);
if (!p) return false;
return singleIndParamKey(p) !== singleIndParamKey(c);
});
const containerEl = containerRef.current;
// 지표 영역 위에 불투명 커버 div 삽입
let cover: HTMLDivElement | null = null;
if (containerEl) {
cover = document.createElement('div');
cover.style.cssText = [
'position:absolute', 'inset:0', 'z-index:900',
'pointer-events:none', 'opacity:1',
'background:var(--bg,#131722)',
].join(';');
containerEl.parentElement?.appendChild(cover);
}
mgr.reloadIndicatorsOnly(indicators).then(() => {
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
}
applyPaneLayout(mgr);
requestAnimationFrame(() => requestAnimationFrame(() => {
if (cover) {
cover.style.transition = 'opacity 0.25s ease';
cover.style.opacity = '0';
setTimeout(() => { cover?.remove(); }, 300);
}
}));
});
} else if (needsRecalc) {
// ── 지표 추가·제거·파라미터 변경 → 지표만 재로드 (메인 캔들 유지) ──
// reloadIndicatorsOnly 는 메인/볼륨 시리즈를 건드리지 않으므로
// 차트 위치 초기화·깜빡임 없이 지표만 갱신된다.
prevSortedPKRef.current = sortedParamKey(indicators);
prevIndKey.current = ik;
// 현재 scroll/zoom 위치를 저장해 reload 후 복원
const savedLR = mgr.getVisibleLogicalRange();
mgr.reloadIndicatorsOnly(indicators).then(() => {
if (candleOnlyModeRef.current) {
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
} else if (mgr.isCandleOnlyLayout()) {
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
}
applyPaneLayout(mgr);
requestAnimationFrame(() => requestAnimationFrame(() => {
if (savedLR) mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
}));
});
} else if (prevSK !== currSK) {
// ── 스타일만 변경 (색상·선폭·plotVisibility) → in-place 업데이트 ────
// 시리즈를 제거·재생성하지 않으므로 pane 재번호 없음
if (!needsRecalc && prevSK !== currSK) {
for (const ind of indicators) {
mgr.applyIndicatorStyle(ind);
}
prevIndKey.current = ik;
syncIndicatorTrackingRefs(indicators);
} else if (
removedIds.length > 0
&& addedConfigs.length === 0
&& paramsChangedConfigs.length === 0
) {
const savedLR = mgr.getVisibleLogicalRange();
indicatorSyncInFlightRef.current = true;
mgr.removeIndicators(removedIds);
afterIndicatorPaneMutation(mgr);
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
void repairMissingIndicators(mgr, indicators);
} else if (
addedConfigs.length > 0
&& removedIds.length === 0
&& paramsChangedConfigs.length === 0
) {
const savedLR = mgr.getVisibleLogicalRange();
indicatorSyncInFlightRef.current = true;
void mgr.addIndicatorsBatch(addedConfigs).then(() => {
afterIndicatorPaneMutation(mgr);
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
});
} else if (
paramsChangedConfigs.length > 0
&& removedIds.length === 0
&& addedConfigs.length === 0
) {
const savedLR = mgr.getVisibleLogicalRange();
indicatorSyncInFlightRef.current = true;
void mgr.refreshIndicators(paramsChangedConfigs).then(() => {
afterIndicatorPaneMutation(mgr);
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
});
} else if (isReorderOnly) {
reloadIndicatorsWithCover(mgr, indicators, () => {
syncIndicatorTrackingRefs(indicators);
});
} else {
const change = classifyIndicatorChartChange(prevList, indicators);
if (change.mode === 'full') {
reloadIndicatorsWithCover(mgr, indicators, () => {
syncIndicatorTrackingRefs(indicators);
});
} else {
syncIndicatorTrackingRefs(indicators);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]);
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators]);
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
useEffect(() => {
if (!chartVisible) return;
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
@@ -1004,10 +1136,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
);
return () => { timers.forEach(clearTimeout); };
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]);
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible]);
// 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구
useEffect(() => {
if (!chartVisible) return;
const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
@@ -1018,19 +1151,22 @@ const TradingChart: React.FC<TradingChartProps> = ({
const timers = [120, 400, 900].map(delay =>
window.setTimeout(() => {
if (reloadInFlightRef.current) return;
if (reloadInFlightRef.current || indicatorSyncInFlightRef.current) return;
const m = managerRef.current;
if (!m || !m.hasMainSeries()) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
const exp = countExpectedVisibleIndicators(indicatorsRef.current);
if (exp === 0 || m.getLoadedIndicatorCount() >= exp) return;
prevBarsKey.current = '';
queueIndicatorRecovery();
void repairMissingIndicators(m, indicatorsRef.current).then(() => {
if (m.getLoadedIndicatorCount() >= exp) return;
prevBarsKey.current = '';
queueIndicatorRecovery();
});
}, delay),
);
return () => { timers.forEach(clearTimeout); };
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, queueIndicatorRecovery]);
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, queueIndicatorRecovery, repairMissingIndicators]);
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {