import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react'; import type { MouseEventParams, Time } from 'lightweight-charts'; import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types'; import { ChartManager } from '../utils/ChartManager'; import { useChartTimeFormat } from '../utils/chartTimeFormat'; import { setIndicatorChartContext } from '../utils/indicatorRegistry'; 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?.parentElement) return null; const cover = document.createElement('div'); cover.className = 'chart-loading chart-indicator-reload-cover'; const spinner = document.createElement('div'); spinner.className = 'loading-spinner'; spinner.setAttribute('role', 'status'); spinner.setAttribute('aria-label', '지표 로딩 중'); cover.appendChild(spinner); 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[], barsMarket: string | null | undefined, market: string, ): boolean { if (barData.length < 2) return false; if (barsMarket === undefined) return true; if (barsMarket == null || barsMarket !== market) return false; if (!market.startsWith('KRW-')) return true; if (barData.length >= MIN_BARS_FOR_FULL_RELOAD) return true; return true; } import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas'; import PaneLegend, { type PaneLegendProps } from './PaneLegend'; import ChartRightToolbar from './ChartRightToolbar'; import CandlePaneTimeAxis from './CandlePaneTimeAxis'; import ChartHoverToolbar from './ChartHoverToolbar'; import ChartMagnifier from './ChartMagnifier'; import ChartContextMenu from './ChartContextMenu'; import CandlePaneControls from './CandlePaneControls'; import { getKoreanName } from '../utils/marketNameCache'; import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync'; import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; import PaneSeparatorOverlay from './PaneSeparatorOverlay'; interface TradingChartProps { bars: OHLCVBar[]; /** bars 가 현재 market 과 일치할 때만 전달 (없으면 bars.length>0 이면 준비된 것으로 간주) */ barsMarket?: string | null; /** BB 등 다른 심볼 계산용 */ market?: string; timeframe?: Timeframe; chartType: ChartType; theme: Theme; mode: ChartMode; indicators: IndicatorConfig[]; drawingTool: string; drawings: Drawing[]; logScale: boolean; drawingsLocked?: boolean; drawingsVisible?: boolean; onCrosshair: (data: LegendData | null) => void; onManagerReady: (mgr: ChartManager) => void; onAddDrawing: (d: Drawing) => void; /** 시리즈 단일 클릭 → (indicatorId | '__main__' | null, 패인 좌측상단 screen 좌표) */ onSeriesClick?: (entryId: string | null, point: { x: number; y: number }) => void; /** 시리즈 더블 클릭 → 설정 모달 직접 오픈용 (패인 좌측상단 screen 좌표 포함) */ onSeriesDoubleClick?: (entryId: string | null, point: { x: number; y: number }) => void; /** pane 레전드 레이블 호버 */ onHoverPaneLegend?: (id: string, sx: number, sy: number) => void; /** pane 레전드 레이블 이탈 */ onLeavePaneLegend?: () => void; /** 멀티차트 전체 보기: 이 차트를 단일 모드로 확장 (있으면 hover toolbar 버튼에 연결) */ onFullView?: () => void; /** 현재 선택된 드로잉 ID (핸들 렌더용) */ selectedDrawingId?: string | null; /** cursor 모드에서 드로잉 단일 클릭 */ onDrawingClick?: (id: string | null, screenX: number, screenY: number) => void; /** cursor 모드에서 드로잉 더블 클릭 */ onDrawingDoubleClick?: (id: string, screenX: number, screenY: number) => void; /** 돋보기 활성 여부 */ magnifierEnabled?: boolean; /** 돋보기 닫기 콜백 */ onMagnifierClose?: () => void; /** * 데이터 로드 완료 콜백 — reloadAll 이 setInitialVisibleRange 까지 마친 뒤 호출. * ChartSlot 에서 멀티차트 첫 마운트 시 sync range 를 재적용하는 데 사용. */ onDataLoaded?: () => void; /** setData 직후 캔들·볼륨 반영 완료 — 실시간 틱 허용 시점 (지표 로드 전) */ onCandlesReady?: () => void; /** 보조지표 pane 순서 변경 (드래그 핸들): fromId 를 insertBeforeId 앞으로 이동 (null = 맨 뒤) */ onReorderIndicators?: (fromId: string, insertBeforeId: string | null) => void; /** 보조지표 pane 병합 */ onMergeIndicators?: (fromId: string, intoId: string) => void; /** 병합 pane 분리 */ onSplitIndicatorPane?: (hostId: string) => void; /** 지표 단독 전체화면 확장 */ onExpandIndicator?: (id: string) => void; /** 지표 제거 (X 버튼) */ onRemoveIndicator?: (id: string) => void; /** 보조지표 pane 복사 */ onDuplicateIndicator?: (id: string) => void; /** 현재 단독 전체화면 중인 지표 id */ focusedIndicatorId?: string | null; /** 전체화면 → 전체 보기 복원 */ onRestoreIndicators?: () => void; /** 지표 표시/숨기기 */ onToggleIndicatorHidden?: (id: string) => void; /** 지표 설정 모달 열기 */ onOpenIndicatorSettings?: (id: string) => void; /** 우클릭 메뉴에서 매수·매도 선택 시 */ onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void; /** 표시 시간대 (IANA) */ displayTimezone?: string; /** 거래량 pane 표시 (기본 true) */ volumeVisible?: boolean; /** 하단 호버 줌·스크롤 툴바 (기본 true) */ showHoverToolbar?: boolean; /** 보조지표 우측 가격축 라벨·설명 (차트 설정, 기본 true) */ seriesPriceLabelsEnabled?: boolean; /** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */ chartVisible?: boolean; /** pane(캔들·거래량·보조지표) 구분선 */ paneSeparatorOptions?: ChartPaneSeparatorOptions; /** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */ paneLayoutClamp?: boolean; /** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */ showPaneLegend?: boolean; /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ crosshairInfoVisible?: boolean; } const TradingChart: React.FC = ({ bars, barsMarket, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings, logScale, drawingsLocked = false, drawingsVisible = true, onCrosshair, onManagerReady, onAddDrawing, onSeriesClick, onSeriesDoubleClick, onHoverPaneLegend, onLeavePaneLegend, onFullView, selectedDrawingId, onDrawingClick, onDrawingDoubleClick, magnifierEnabled = false, onMagnifierClose, onDataLoaded, onCandlesReady, onReorderIndicators, onMergeIndicators, onSplitIndicatorPane, onExpandIndicator, onRemoveIndicator, onDuplicateIndicator, focusedIndicatorId, onRestoreIndicators, onToggleIndicatorHidden, onOpenIndicatorSettings, onTradeOrderRequest, displayTimezone = DEFAULT_DISPLAY_TIMEZONE, volumeVisible = true, showHoverToolbar = true, seriesPriceLabelsEnabled = true, chartVisible = true, paneSeparatorOptions, paneLayoutClamp = false, showPaneLegend = true, crosshairInfoVisible = true, }) => { const containerRef = useRef(null); const wrapperRef = useRef(null); // 스크롤 래퍼 const managerRef = useRef(null); const barsRef = useRef([]); // 드로잉 최신 참조 (캡처 리스너에서 closure 없이 접근) const drawingsRef = useRef(drawings); const drawingToolRef = useRef(drawingTool); const drawingsVisibleRef = useRef(drawingsVisible); const drawingsLockedRef = useRef(drawingsLocked); const modeRef = useRef(mode); const onDrawingClickRef = useRef(onDrawingClick); const onDrawingDoubleClickRef = useRef(onDrawingDoubleClick); const canPanRef = useRef(false); const panStateRef = useRef({ active: false, lastX: 0, lastY: 0, moved: false, /** 0=캔들 pane — 세로 패닝 허용 */ originPaneIndex: 0, }); const suppressClickRef = useRef(false); const seriesDblSuppressRef = useRef(false); const toggleCandleOnlyRef = useRef<() => void>(() => {}); const candleOnlyModeRef = useRef(false); const lastCrosshairPriceRef = useRef(null); const onTradeOrderRequestRef = useRef(onTradeOrderRequest); onTradeOrderRequestRef.current = onTradeOrderRequest; const onSeriesDoubleClickRef = useRef(onSeriesDoubleClick); onSeriesDoubleClickRef.current = onSeriesDoubleClick; const marketRef = useRef(market); marketRef.current = market; const barsMarketRef = useRef(barsMarket); barsMarketRef.current = barsMarket; const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; price: number; } | null>(null); // 드로잉 더블클릭 감지용 const lastDrawingClickRef = useRef({ id: '', time: 0 }); const drawingSingleTimerRef = useRef | null>(null); // 직전 값 추적 (불필요한 재실행 방지) const prevBarsKey = useRef(''); const prevIndKey = useRef(''); const prevSortedPKRef = useRef(''); // 순서 무관 paramKey (reorder 감지용) const prevIndicatorsListRef = useRef(indicators); const indicatorSyncInFlightRef = useRef(false); const [paneLegendPaused, setPaneLegendPaused] = useState(false); const [chartBodyHeight, setChartBodyHeight] = useState(0); const pendingIndicatorResyncRef = useRef(false); const indicatorReloadGenRef = useRef(0); const prevMarket = useRef(market); const prevMarketTf = useRef(timeframe); const lastAppliedMarketRef = useRef(''); const prevChartType = useRef(chartType); const prevTheme = useRef(theme); const prevLogScale = useRef(logScale); const indicatorsRef = useRef(indicators); const recoveryTimerRef = useRef(null); const resizeDebounceRef = useRef(null); const chartVisibleRef = useRef(chartVisible); const chartTimeFormat = useChartTimeFormat(); const [chartMgr, setChartMgr] = useState(null); /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */ const [candleOnlyMode, setCandleOnlyMode] = useState(false); const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null); useEffect(() => { if (barsMarket === market) { barsRef.current = bars; } else { barsRef.current = []; } }, [bars, barsMarket, market]); useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); useEffect(() => { chartVisibleRef.current = chartVisible; }, [chartVisible]); useEffect(() => { const el = containerRef.current; if (!el) return; const update = () => setChartBodyHeight(el.clientHeight); update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, [chartMgr]); const toggleCandleOnly = useCallback(() => { setCandleOnlyMode(v => !v); }, []); useEffect(() => { toggleCandleOnlyRef.current = toggleCandleOnly; }, [toggleCandleOnly]); useEffect(() => { candleOnlyModeRef.current = candleOnlyMode; }, [candleOnlyMode]); const prevCandleOnlyRef = useRef(false); // 최신 값을 ref에 동기화 (캡처 리스너 closure 탈출) useEffect(() => { setIndicatorChartContext(market, timeframe); }, [market, timeframe]); useEffect(() => { drawingsRef.current = drawings; }, [drawings]); useEffect(() => { drawingToolRef.current = drawingTool; }, [drawingTool]); useEffect(() => { drawingsVisibleRef.current = drawingsVisible; }, [drawingsVisible]); useEffect(() => { drawingsLockedRef.current = drawingsLocked; }, [drawingsLocked]); useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { onDrawingClickRef.current = onDrawingClick; }, [onDrawingClick]); useEffect(() => { onDrawingDoubleClickRef.current = onDrawingDoubleClick; }, [onDrawingDoubleClick]); useEffect(() => { canPanRef.current = drawingTool === 'cursor'; const el = containerRef.current; if (el && !panStateRef.current.active) { el.style.cursor = canPanRef.current ? 'grab' : ''; el.style.touchAction = canPanRef.current ? 'none' : ''; } }, [mode, drawingTool]); const volumeVisibleRef = useRef(volumeVisible); useEffect(() => { volumeVisibleRef.current = volumeVisible; }, [volumeVisible]); useEffect(() => { const mgr = managerRef.current; if (!mgr) return; const wrapperH = wrapperRef.current?.clientHeight ?? 0; mgr.setVolumeVisible(volumeVisible, wrapperH > 0 ? wrapperH : undefined); }, [volumeVisible]); useEffect(() => { managerRef.current?.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled); }, [seriesPriceLabelsEnabled]); useEffect(() => { if (!paneSeparatorOptions) return; managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions); }, [paneSeparatorOptions]); /** * pane 높이 재배분 + 스크롤 컨테이너 확장 * * wrapper.clientHeight 를 정확한 가용 높이로 ChartManager 에 전달합니다. * CSS Grid 레이아웃 초기화 지연으로 wrapperH=0 이 될 수 있으므로, * retryCount 를 통해 최대 6회 자동 재시도합니다 (100→200→300ms…) */ const applyPaneLayout = useCallback((mgr: ChartManager, retryCount = 0) => { const wrapper = wrapperRef.current; const container = containerRef.current; 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)); } return; } let required: number; if (candleOnlyModeRef.current) { mgr.applyCandleOnlyLayout(true, wrapperH); required = wrapperH; } else { if (mgr.isCandleOnlyLayout()) { mgr.restoreFromCandleFullscreen(wrapperH); } required = mgr.resetPaneHeights(wrapperH); } if (paneLayoutClamp) { try { const w = wrapper.clientWidth; if (w > 0 && wrapperH > 0) mgr.resize(w, wrapperH); } catch { /* ok */ } } if (!paneLayoutClamp && required > wrapperH + 4) { container.style.height = `${required}px`; } else { container.style.height = ''; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [paneLayoutClamp]); /** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */ useEffect(() => { const mgr = managerRef.current; if (!mgr || !chartMgr) return; const wasOnly = prevCandleOnlyRef.current; prevCandleOnlyRef.current = candleOnlyMode; if (!wasOnly && candleOnlyMode) { applyPaneLayout(mgr); } else if (wasOnly && !candleOnlyMode && mgr.hasMainSeries()) { const runRestore = () => { const m = managerRef.current; if (!m?.hasMainSeries()) return; const wrapperH = wrapperRef.current?.clientHeight ?? 0; m.restoreFromCandleFullscreen(wrapperH); applyPaneLayout(m); const futureBars = m.hasIchimoku() ? 28 : 0; m.setInitialVisibleRange(DISPLAY_COUNT, futureBars); }; requestAnimationFrame(() => requestAnimationFrame(runRestore)); [200, 500, 1000].forEach(delay => setTimeout(runRestore, delay)); } }, [candleOnlyMode, chartMgr, applyPaneLayout]); // ── 전체 재로드 (데이터 + 인디케이터) ───────────────────────────────────── const reloadSafetyTimers = useRef[]>([]); const reloadGenRef = useRef(0); const reloadInFlightRef = useRef(false); const reloadCoalesceRef = useRef(false); const reloadRetryRef = useRef(0); const reloadAllRef = useRef<( mgr: ChartManager, newBars: OHLCVBar[], ct: ChartType, th: Theme, ls: boolean, inds: IndicatorConfig[], ) => Promise>(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); mgr.notifyPaneLayoutChanged(); requestAnimationFrame(() => requestAnimationFrame(() => { afterIndicatorPaneMutation(mgr); mgr.notifyPaneLayoutChanged(); })); } finally { indicatorSyncInFlightRef.current = false; } }, [afterIndicatorPaneMutation]); /** 지표만 재로드 — 커버 + 스크롤 위치 복원으로 깜빡임·떨림 완화 */ const reloadIndicatorsWithCover = useCallback(( mgr: ChartManager, inds: IndicatorConfig[], onComplete?: () => void, ) => { const cover = createIndicatorReloadCover(containerRef.current); const savedLR = mgr.getVisibleLogicalRange(); const reloadGen = ++indicatorReloadGenRef.current; indicatorSyncInFlightRef.current = true; void mgr.reloadIndicatorsOnly(inds).then(() => { if (reloadGen !== indicatorReloadGenRef.current) { fadeOutIndicatorReloadCover(cover); return; } afterIndicatorPaneMutation(mgr); // resetPaneHeights 직후 레이블 좌표 재동기화 (탭 교체 후 어긋남 방지) mgr.notifyPaneLayoutChanged(); requestAnimationFrame(() => requestAnimationFrame(() => { if (reloadGen !== indicatorReloadGenRef.current) { fadeOutIndicatorReloadCover(cover); return; } restoreLogicalRange(mgr, savedLR); fadeOutIndicatorReloadCover(cover); indicatorSyncInFlightRef.current = false; mgr.notifyPaneLayoutChanged(); [50, 150, 350, 700, 1200].forEach(ms => { setTimeout(() => { if (reloadGen === indicatorReloadGenRef.current) { mgr.notifyPaneLayoutChanged(); } }, ms); }); onComplete?.(); })); }); }, [afterIndicatorPaneMutation, restoreLogicalRange]); /** 설정 저장 등으로 연속 갱신이 겹칠 때 마지막 상태로 한 번 더 동기화 */ const flushPendingIndicatorResync = useCallback(( mgr: ChartManager, completedGen: number, ) => { if (!pendingIndicatorResyncRef.current) return; if (completedGen !== indicatorReloadGenRef.current) return; pendingIndicatorResyncRef.current = false; const latest = indicatorsRef.current; indicatorSyncInFlightRef.current = true; const flushGen = ++indicatorReloadGenRef.current; reloadIndicatorsWithCover(mgr, latest, () => { if (flushGen !== indicatorReloadGenRef.current) { indicatorSyncInFlightRef.current = false; return; } syncIndicatorTrackingRefs(latest); indicatorSyncInFlightRef.current = false; }); }, [reloadIndicatorsWithCover, syncIndicatorTrackingRefs]); const queueFullReload = useCallback(() => { if (!chartVisibleRef.current) return; if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current); recoveryTimerRef.current = window.setTimeout(() => { recoveryTimerRef.current = null; const m = managerRef.current; const barData = barsRef.current; const inds = indicatorsRef.current; if (!m || barData.length === 0) return; if (!canApplyChartBars(barData, barsMarket, market)) return; prevBarsKey.current = ''; reloadAllRef.current?.( m, barData, prevChartType.current, prevTheme.current, prevLogScale.current, inds, ); }, 80); }, [market, barsMarket]); const queueIndicatorRecovery = useCallback(() => { if (!chartVisibleRef.current) return; if (reloadInFlightRef.current) return; if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current); recoveryTimerRef.current = window.setTimeout(() => { recoveryTimerRef.current = null; const m = managerRef.current; const barData = barsRef.current; const inds = indicatorsRef.current; if (!m || barData.length === 0) return; if (!canApplyChartBars(barData, barsMarket, market)) return; const expected = countExpectedVisibleIndicators(inds); if (expected === 0) return; const loadedIds = m.getLoadedIndicatorIds(); if (chartHasStaleIndicators(loadedIds, inds)) { reloadIndicatorsWithCover(m, inds, () => { prevBarsKey.current = barsKey(barData, market); syncIndicatorTrackingRefs(inds); applyPaneLayout(m); }); return; } if (loadedIds.size >= expected) return; prevBarsKey.current = ''; const ct = prevChartType.current; const th = prevTheme.current; const ls = prevLogScale.current; if (m.hasMainSeries()) { void repairMissingIndicators(m, inds).then(() => { if ( m.getLoadedIndicatorCount() >= expected && !chartHasStaleIndicators(m.getLoadedIndicatorIds(), inds) ) { prevBarsKey.current = barsKey(barData, market); syncIndicatorTrackingRefs(inds); applyPaneLayout(m); return; } reloadAllRef.current?.(m, barData, ct, th, ls, inds); }); return; } reloadAllRef.current?.(m, barData, ct, th, ls, inds); }, 120); }, [market, barsMarket, applyPaneLayout, repairMissingIndicators, reloadIndicatorsWithCover, syncIndicatorTrackingRefs]); /** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */ const commitCandleLayout = useCallback((mgr: ChartManager) => { requestAnimationFrame(() => { applyPaneLayout(mgr); requestAnimationFrame(() => { const futureBars = mgr.hasIchimoku() ? 28 : 0; mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); }); }); }, [applyPaneLayout]); const reloadAll = useCallback(async ( mgr: ChartManager, newBars: OHLCVBar[], ct: ChartType, th: Theme, ls: boolean, inds: IndicatorConfig[], ) => { if (newBars.length === 0) return; if (reloadInFlightRef.current) { reloadCoalesceRef.current = true; barsRef.current = newBars; return; } barsRef.current = newBars; reloadInFlightRef.current = true; try { do { reloadCoalesceRef.current = false; const gen = reloadGenRef.current; const barData = barsRef.current.length >= 2 ? barsRef.current : newBars; if (barData.length < 2) break; reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; barsRef.current = barData; mgr.setData(barData, ct, th); if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; break; } 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); } if (stale) break; if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; break; } const expected = countExpectedVisibleIndicators(inds); const loaded = mgr.getLoadedIndicatorCount(); if (expected > 0 && loaded < expected) { prevBarsKey.current = ''; if (reloadRetryRef.current < 4 && mgr.hasMainSeries()) { reloadRetryRef.current += 1; await mgr.reloadIndicatorsOnly(inds); if (gen !== reloadGenRef.current) break; } if (mgr.getLoadedIndicatorCount() < expected) { commitCandleLayout(mgr); if (reloadRetryRef.current < 6) { reloadRetryRef.current += 1; queueIndicatorRecovery(); } break; } } reloadRetryRef.current = 0; prevChartType.current = ct; prevTheme.current = th; prevLogScale.current = ls; prevBarsKey.current = barsKey(barData, market); syncIndicatorTrackingRefs(inds); lastAppliedMarketRef.current = market; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { applyPaneLayout(mgr); requestAnimationFrame(() => { const futureBars = mgr.hasIchimoku() ? 28 : 0; mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); resolve(); }); }))); if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; break; } onDataLoaded?.(); reloadSafetyTimers.current = [300, 700, 1400].map(delay => setTimeout(() => { const m = managerRef.current; if (!m || !m.hasMainSeries()) return; const expectedLater = countExpectedVisibleIndicators(indicatorsRef.current); if (expectedLater > 0 && m.getLoadedIndicatorCount() < expectedLater) { prevBarsKey.current = ''; queueIndicatorRecovery(); 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?.(); }, delay) ); } while (reloadCoalesceRef.current); } finally { reloadInFlightRef.current = false; if (reloadCoalesceRef.current) { reloadCoalesceRef.current = false; queueFullReload(); } } }, [applyPaneLayout, commitCandleLayout, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); useEffect(() => { reloadAllRef.current = reloadAll; }, [reloadAll]); // ── 차트 초기화 (마운트 시 1회) ────────────────────────────────────────── useEffect(() => { if (!containerRef.current) return; const mgr = new ChartManager(containerRef.current, theme, { compactPaneLayout: paneLayoutClamp, autoSize: !paneLayoutClamp, }); managerRef.current = mgr; setChartMgr(mgr); if (!volumeVisibleRef.current) { const wrapperH = wrapperRef.current?.clientHeight ?? 0; mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined); } mgr.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled); if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions); onManagerReady(mgr); const unsub = mgr.subscribeCrosshair((p: MouseEventParams