import React, { useRef, useEffect, useLayoutEffect, useState, useCallback, memo } 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 { formatUpbitKrwPrice } from '../utils/safeFormat'; 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)); /** 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'); 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, { type CandleOverlayToggleItem } from './ChartRightToolbar'; import CandlePaneTimeAxis from './CandlePaneTimeAxis'; import ChartHoverToolbar from './ChartHoverToolbar'; import ChartMagnifier from './ChartMagnifier'; import ChartContextMenu from './ChartContextMenu'; import CandlePaneControls from './CandlePaneControls'; import IndicatorPaneControls from './IndicatorPaneControls'; 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'; import TradeSignalLabelOverlay from './TradeSignalLabelOverlay'; import { centerChartOnSignalBar } from '../utils/tradeSignalChartMarkers'; 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) */ candleAreaPriceLabelsEnabled?: boolean; /** 하단 보조지표 pane 가격축 라벨·설명 (기본 true) */ indicatorAreaPriceLabelsEnabled?: boolean; /** @deprecated candleArea + indicatorArea 동일 값 */ seriesPriceLabelsEnabled?: boolean; /** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */ chartVisible?: boolean; /** pane(캔들·거래량·보조지표) 구분선 */ paneSeparatorOptions?: ChartPaneSeparatorOptions; /** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */ paneLayoutClamp?: boolean; /** paneLayoutClamp=true 환경에서도 캔들/보조지표 비율 드래그 핸들을 표시 (백테스팅 차트 등) */ showPaneResizeHandle?: boolean; /** 캔들+거래량 vs 보조 pane 높이 비율 (aux 0 이면 미적용) */ paneAreaRatio?: { candle: number; aux: number } | null; /** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true) */ showPaneLegend?: boolean; /** pane 레전드와 별도 — 우측 드래그·설정 툴바 (미지정 시 showPaneLegend 와 동일) */ showChartRightToolbar?: boolean; /** 캔들 pane 우측 상단 — 이동평균·일목·볼린저 on/off (미니 차트 등, showChartRightToolbar 없이도 가능) */ showCandleOverlayControls?: boolean; /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ crosshairInfoVisible?: boolean; /** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */ dataLoading?: boolean; /** chartPaintReady 변경 알림 (워크스페이스 전체 로딩 오버레이용) */ onChartPaintReadyChange?: (ready: boolean) => void; /** 마운트 시 캔들 pane 전체보기(하단 보조지표 pane 숨김) — 가상매매 카드 등 */ defaultCandleOnly?: boolean; /** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */ showCandlePaneControls?: boolean; /** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */ candleOverlayToggles?: CandleOverlayToggleItem[]; onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void; customOverlayActive?: boolean; onCustomOverlayActiveChange?: (active: boolean) => void; onOpenCustomOverlaySettings?: () => void; custom1OverlayActive?: boolean; onCustom1OverlayActiveChange?: (active: boolean) => void; onOpenCustom1OverlaySettings?: () => void; /** 9·20일 신고가·신저가 라벨 표시 (실시간 차트 우측 툴바) */ priceExtremeLabelsVisible?: boolean; onTogglePriceExtremeLabels?: () => void; /** 미니 차트 — plot 너비에 맞는 초기 x축 캔들 수 (미지정 시 DISPLAY_COUNT) */ resolveInitialDisplayCount?: (plotWidth: number) => number; /** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */ skipViewportRecovery?: boolean; /** true — 캔들 숨김, 보조지표 pane 선형 그래프만 표시 (시그널 상세 하단 차트 등) */ auxIndicatorOnly?: boolean; /** 지정 시 초기 visible range 대신 시그널 봉 시간축 중앙 정렬 */ centerOnSignalTime?: number; /** centerOnSignalTime 사용 시 화면에 보일 봉 수 (미지정 시 전체) */ centerVisibleBarCount?: number; } 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, candleAreaPriceLabelsEnabled, indicatorAreaPriceLabelsEnabled, seriesPriceLabelsEnabled, chartVisible = true, paneSeparatorOptions, paneLayoutClamp = false, showPaneResizeHandle = false, paneAreaRatio = null, showPaneLegend = true, showChartRightToolbar, showCandleOverlayControls = false, crosshairInfoVisible = true, dataLoading = false, onChartPaintReadyChange, defaultCandleOnly = false, showCandlePaneControls = true, candleOverlayToggles, onToggleCandleOverlay, customOverlayActive, onCustomOverlayActiveChange, onOpenCustomOverlaySettings, custom1OverlayActive, onCustom1OverlayActiveChange, onOpenCustom1OverlaySettings, priceExtremeLabelsVisible, onTogglePriceExtremeLabels, resolveInitialDisplayCount, skipViewportRecovery = false, auxIndicatorOnly = false, centerOnSignalTime, centerVisibleBarCount, }) => { const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend; const showOverlayControlsOnly = !!( showCandleOverlayControls && candleOverlayToggles?.length && onToggleCandleOverlay && !effectiveShowChartRightToolbar ); 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 paneAreaRatioRef = useRef(paneAreaRatio); paneAreaRatioRef.current = paneAreaRatio; const paneLayoutClampRef = useRef(paneLayoutClamp); paneLayoutClampRef.current = paneLayoutClamp; /** 사용자 드래그로 설정한 pane 비율 — prop 변경 시 초기화 */ const userDraggedRatioRef = useRef<{ candle: number; aux: number } | null>(null); 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); /** 캔들 pane 하단 Y 위치 (드래그 핸들용) — null 이면 보조지표 없음 */ const [candlePaneBottomY, setCandlePaneBottomY] = useState(null); const paneResizeDragRef = useRef<{ startClientY: number; startCandleH: number; wrapperH: number; } | null>(null); 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 resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount); const skipViewportRecoveryRef = useRef(skipViewportRecovery); const auxIndicatorOnlyRef = useRef(auxIndicatorOnly); const centerOnSignalTimeRef = useRef(centerOnSignalTime); const centerVisibleBarCountRef = useRef(centerVisibleBarCount); const lastViewportFitRef = useRef<{ count: number; width: number } | null>(null); useEffect(() => { resolveInitialDisplayCountRef.current = resolveInitialDisplayCount; }, [resolveInitialDisplayCount]); useEffect(() => { skipViewportRecoveryRef.current = skipViewportRecovery; }, [skipViewportRecovery]); useEffect(() => { auxIndicatorOnlyRef.current = auxIndicatorOnly; }, [auxIndicatorOnly]); useEffect(() => { centerOnSignalTimeRef.current = centerOnSignalTime; }, [centerOnSignalTime]); useEffect(() => { centerVisibleBarCountRef.current = centerVisibleBarCount; }, [centerVisibleBarCount]); const applySnapshotViewport = useCallback((mgr: ChartManager) => { const t = centerOnSignalTimeRef.current; if (t != null && Number.isFinite(t)) { centerChartOnSignalBar(mgr, t, centerVisibleBarCountRef.current); } else { mgr.fitContent(); } }, []); const chartTimeFormat = useChartTimeFormat(); const [chartMgr, setChartMgr] = useState(null); /** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */ const [chartPaintReady, setChartPaintReady] = useState(false); /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */ const candleOnlyLocked = defaultCandleOnly; const [candleOnlyMode, setCandleOnlyMode] = useState(defaultCandleOnly); 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(() => { if (dataLoading) setChartPaintReady(false); }, [dataLoading]); useEffect(() => { onChartPaintReadyChange?.(chartPaintReady && !dataLoading); }, [chartPaintReady, dataLoading, onChartPaintReadyChange]); 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]); /** 캔들 pane 하단 Y 추적 — 보조지표 있을 때만 드래그 핸들 표시 */ useEffect(() => { const mgr = managerRef.current; if (!mgr || !chartMgr) return; const updateHandle = () => { const layouts = mgr.getPaneLayouts(); const candle = layouts.find(l => l.paneIndex === 0); // pane 1 = 볼륨(표시 시) 또는 첫 보조지표(볼륨 숨김) — paneIndex>=2 만 보면 후자에서 핸들 누락 const subPaneHeight = layouts .filter(l => l.paneIndex > 0) .reduce((sum, l) => sum + l.height, 0); if (candle && candle.height > 20 && subPaneHeight > 60) { setCandlePaneBottomY(candle.topY + candle.height); } else { setCandlePaneBottomY(null); } }; updateHandle(); return mgr.subscribePaneLayout(updateHandle); }, [chartMgr]); /** 캔들/보조지표 영역 분리선 드래그 핸들러 */ const onPaneResizePointerDown = useCallback((e: React.PointerEvent) => { const mgr = managerRef.current; const wrapper = wrapperRef.current; if (!mgr || !wrapper) return; e.preventDefault(); e.stopPropagation(); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); const layouts = mgr.getPaneLayouts(); const candle = layouts.find(l => l.paneIndex === 0); paneResizeDragRef.current = { startClientY: e.clientY, startCandleH: candle?.height ?? 200, wrapperH: wrapper.clientHeight, }; }, []); const onPaneResizePointerMove = useCallback((e: React.PointerEvent) => { const drag = paneResizeDragRef.current; const mgr = managerRef.current; const wrapper = wrapperRef.current; const container = containerRef.current; if (!drag || !mgr || !wrapper || !container) return; const dy = e.clientY - drag.startClientY; const H = drag.wrapperH; const MIN_CANDLE = 100; const MIN_AUX = 60; const newCandleH = Math.max(MIN_CANDLE, Math.min(H - MIN_AUX, drag.startCandleH + dy)); const newCandle = newCandleH / H; const newAux = 1 - newCandle; mgr.setPaneAreaRatio(newCandle, newAux); // applyPaneLayout 이 ResizeObserver 로 재실행돼도 이 비율 유지 userDraggedRatioRef.current = { candle: newCandle, aux: newAux }; const required = mgr.resetPaneHeights(H); // paneLayoutClamp 모드: 컨테이너를 래퍼 높이에 고정 (overflow:hidden 으로 클립) // 일반 모드: 보조지표 필요 시 컨테이너 확장하여 스크롤 영역 확보 const newContainerH = paneLayoutClampRef.current ? H : (required > H + 4 ? required : H); if (container.style.height !== `${newContainerH}px`) { container.style.height = `${newContainerH}px`; } }, []); const onPaneResizePointerUp = useCallback(() => { paneResizeDragRef.current = null; }, []); const toggleCandleOnly = useCallback(() => { if (candleOnlyLocked) return; setCandleOnlyMode(v => !v); }, [candleOnlyLocked]); 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' : ''; } }, [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]); const resolvedCandlePriceLabels = candleAreaPriceLabelsEnabled ?? seriesPriceLabelsEnabled ?? true; const resolvedIndicatorPriceLabels = indicatorAreaPriceLabelsEnabled ?? seriesPriceLabelsEnabled ?? true; useEffect(() => { managerRef.current?.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels); }, [resolvedCandlePriceLabels]); useEffect(() => { managerRef.current?.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels); }, [resolvedIndicatorPriceLabels]); useEffect(() => { if (!paneSeparatorOptions) return; managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions); managerRef.current?.refreshPaneSeparatorOverlay(); }, [paneSeparatorOptions]); /** * pane 높이 재배분 + 스크롤 컨테이너 확장 * * wrapper.clientHeight 를 정확한 가용 높이로 ChartManager 에 전달합니다. * CSS Grid 레이아웃 초기화 지연으로 wrapperH=0 이 될 수 있으므로, * retryCount 를 통해 최대 30회 자동 재시도합니다 (원격·테더링 등 느린 레이아웃 대응). */ 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 < MIN_VALID_LAYOUT_HEIGHT) { if (retryCount < LAYOUT_RETRY_MAX) { const delay = Math.min(120 * (retryCount + 1), 500); setTimeout(() => applyPaneLayout(mgr, retryCount + 1), delay); } return; } const candleOnly = candleOnlyModeRef.current; let required: number; if (candleOnly) { mgr.applyCandleOnlyLayout(true, wrapperH); required = wrapperH; } else { if (auxIndicatorOnlyRef.current) { mgr.applyAuxIndicatorOnlyLayout(true); } else if (mgr.isCandleOnlyLayout()) { mgr.restoreFromCandleFullscreen(wrapperH); } // 사용자 드래그 비율 우선, 없으면 prop 기반 비율 const ratio = userDraggedRatioRef.current ?? paneAreaRatioRef.current; if (ratio && ratio.aux > 0) { mgr.setPaneAreaRatio(ratio.candle, ratio.aux); } else { mgr.setPaneAreaRatio(0, 0); } required = mgr.resetPaneHeights(wrapperH); } // LWC는 chart-container에 마운트 — 우측 툴바(44px) 제외 너비로 맞춰 가격 라벨이 가리지 않음 const plotW = Math.max(0, container.clientWidth); if (candleOnly || paneLayoutClamp) { // 전체보기·미니차트: 래퍼 높이에 고정 resize (autoSize 피드백 루프·무한 스크롤 방지) container.style.height = `${wrapperH}px`; try { if (plotW > 0 && wrapperH > 0) mgr.resize(plotW, wrapperH); } catch { /* ok */ } if (candleOnly) wrapper.scrollTop = 0; } else if (required > wrapperH + 4) { // 보조지표 있음: 컨테이너를 required 크기로 확장해 스크롤 영역 확보 container.style.height = `${required}px`; } else { // 기본 상태: 항상 명시적 픽셀 높이를 부여 (height:'' → CSS height:100% → auto 해석 문제 방지) // autoSize:true LWC가 container.contentRect.height를 읽어 정확히 wrapperH 에 맞게 렌더링 container.style.height = `${wrapperH}px`; try { if (plotW > 0 && wrapperH > 0) mgr.resize(plotW, wrapperH); } catch { /* ok */ } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [paneLayoutClamp, paneAreaRatio]); /** paneAreaRatio prop 이 외부에서 변경되면 드래그 비율 초기화 (전략 변경 등) */ useEffect(() => { userDraggedRatioRef.current = null; }, [paneAreaRatio]); const getInitialDisplayCount = useCallback((): number => { const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0; const resolver = resolveInitialDisplayCountRef.current; return resolver ? resolver(plotW) : DISPLAY_COUNT; }, []); const applyInitialVisibleRange = useCallback((mgr: ChartManager) => { const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0; if (plotW <= 0) return; const count = getInitialDisplayCount(); const prev = lastViewportFitRef.current; if (prev && prev.count === count && Math.abs(prev.width - plotW) < 4) return; lastViewportFitRef.current = { count, width: plotW }; const fb = mgr.hasIchimoku() ? 28 : 0; mgr.setInitialVisibleRange(count, fb); }, [getInitialDisplayCount]); /** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */ const normalizeChartViewport = useCallback((mgr: ChartManager) => { applyPaneLayout(mgr); requestAnimationFrame(() => { try { mgr.autoScale(); } catch { /* ok */ } if (centerOnSignalTimeRef.current != null && Number.isFinite(centerOnSignalTimeRef.current)) { applySnapshotViewport(mgr); } else { applyInitialVisibleRange(mgr); } }); }, [applyPaneLayout, applyInitialVisibleRange, applySnapshotViewport]); /** 캔들 확대 보기 → 원복 직후 가격·시간축·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); applyInitialVisibleRange(m); }; requestAnimationFrame(() => requestAnimationFrame(runRestore)); [200, 500, 1000].forEach(delay => setTimeout(runRestore, delay)); } }, [candleOnlyMode, chartMgr, applyPaneLayout, applyInitialVisibleRange]); // ── 전체 재로드 (데이터 + 인디케이터) ───────────────────────────────────── 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 (auxIndicatorOnlyRef.current) { mgr.applyAuxIndicatorOnlyLayout(true); } 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(() => { applyInitialVisibleRange(mgr); }); }); }, [applyPaneLayout, applyInitialVisibleRange]); 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; setChartPaintReady(false); 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); lastAppliedMarketRef.current = market; const sortedInds = sortIndicatorsForPaneLoad(inds); if (sortedInds.length > 0) { await mgr.addIndicatorsBatch(sortedInds); } 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) { 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(() => { normalizeChartViewport(mgr); requestAnimationFrame(() => { onCandlesReady?.(); setChartPaintReady(true); resolve(); }); }))); if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; break; } onDataLoaded?.(); reloadSafetyTimers.current = [300, 700, 1400, 2500].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; } if (skipViewportRecoveryRef.current) return; const w = wrapperRef.current; if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return; normalizeChartViewport(m); if (delay === 2500) onDataLoaded?.(); }, delay) ); } while (reloadCoalesceRef.current); } finally { reloadInFlightRef.current = false; if (reloadCoalesceRef.current) { reloadCoalesceRef.current = false; queueFullReload(); return; } const m = managerRef.current; if (m?.hasMainSeries()) { // 메인 캔들만 준비되어도 paint·실시간 틱 허용 (지표는 백그라운드 복구) setChartPaintReady(true); onCandlesReady?.(); } } }, [normalizeChartViewport, 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, volumePaneEnabled: volumeVisibleRef.current, }); managerRef.current = mgr; setChartMgr(mgr); if (!volumeVisibleRef.current) { const wrapperH = wrapperRef.current?.clientHeight ?? 0; mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined); } mgr.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels); mgr.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels); if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions); if (auxIndicatorOnlyRef.current) mgr.applyAuxIndicatorOnlyLayout(true); onManagerReady(mgr); const unsub = mgr.subscribeCrosshair((p: MouseEventParams