chart error fix on slow network

This commit is contained in:
Macbook
2026-05-30 03:13:07 +09:00
parent b4dcebf758
commit e7a6c22cc2
3 changed files with 103 additions and 16 deletions
+1
View File
@@ -843,6 +843,7 @@ html.theme-blue {
overflow: hidden;
border-right: 1px solid var(--border);
min-width: 0;
min-height: 0;
}
/* ── Chart Legend ───────────────────────────────────────────────── */
+76 -16
View File
@@ -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<TradingChartProps> = ({
*
* 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<TradingChartProps> = ({
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<TradingChartProps> = ({
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<TradingChartProps> = ({
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<TradingChartProps> = ({
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<TradingChartProps> = ({
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<TradingChartProps> = ({
// 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<TradingChartProps> = ({
}
}, 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<TradingChartProps> = ({
// 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<TradingChartProps> = ({
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;
+26
View File
@@ -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 값, ...] }