import React, { useRef, useEffect, 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 { setIndicatorChartContext } from '../utils/indicatorRegistry'; import { DISPLAY_COUNT } from '../hooks/useUpbitData'; /** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 1~2봉만 로드되는 깨짐 방지) */ const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4)); import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas'; import PaneLegend, { type PaneLegendProps } from './PaneLegend'; 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'; 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; /** 보조지표 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; /** 우클릭 메뉴에서 매수·매도 선택 시 */ onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void; /** 표시 시간대 (IANA) */ displayTimezone?: string; } 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, onReorderIndicators, onMergeIndicators, onSplitIndicatorPane, onExpandIndicator, onRemoveIndicator, onDuplicateIndicator, focusedIndicatorId, onRestoreIndicators, onTradeOrderRequest, displayTimezone = DEFAULT_DISPLAY_TIMEZONE, }) => { 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 [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 prevMarket = useRef(market); const prevMarketTf = useRef(timeframe); const prevChartType = useRef(chartType); const prevTheme = useRef(theme); const prevLogScale = useRef(logScale); const indicatorsRef = useRef(indicators); const recoveryTimerRef = useRef | null>(null); const [chartMgr, setChartMgr] = useState(null); /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */ const [candleOnlyMode, setCandleOnlyMode] = useState(false); useEffect(() => { barsRef.current = bars; }, [bars]); useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); 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]); /** * 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 (required > wrapperH + 4) { container.style.height = `${required}px`; } else { container.style.height = ''; } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /** 캔들 확대 보기 → 원복 직후 가격·시간축·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 reloadRetryRef = useRef(0); const reloadAllRef = useRef<( mgr: ChartManager, newBars: OHLCVBar[], ct: ChartType, th: Theme, ls: boolean, inds: IndicatorConfig[], ) => Promise>(async () => {}); const queueFullReload = useCallback(() => { 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 (barsMarket !== undefined && barsMarket !== market) return; if (market.startsWith('KRW-') && barData.length < MIN_BARS_FOR_FULL_RELOAD) return; prevBarsKey.current = ''; reloadAllRef.current?.( m, barData, prevChartType.current, prevTheme.current, prevLogScale.current, inds, ); }, 80); }, [market, barsMarket]); const queueIndicatorRecovery = useCallback(() => { 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 (barsMarket !== undefined && barsMarket !== market) return; const expected = countExpectedVisibleIndicators(inds); if (expected === 0) return; if (m.getLoadedIndicatorCount() >= expected) return; prevBarsKey.current = ''; const ct = prevChartType.current; const th = prevTheme.current; const ls = prevLogScale.current; if (m.hasMainSeries()) { void m.reloadIndicatorsOnly(inds).then(() => { if (m.getLoadedIndicatorCount() >= expected) { prevBarsKey.current = barsKey(barData, market); prevIndKey.current = indKey(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]); const reloadAll = useCallback(async ( mgr: ChartManager, newBars: OHLCVBar[], ct: ChartType, th: Theme, ls: boolean, inds: IndicatorConfig[], ) => { if (newBars.length === 0) return; const gen = ++reloadGenRef.current; reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; barsRef.current = newBars; mgr.setData(newBars, ct, th); if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; queueFullReload(); return; } mgr.setTheme(th); mgr.setLogScale(ls); mgr.setDisplayTimezone(displayTimezone, timeframe); for (const ind of inds) { if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; queueFullReload(); return; } await mgr.addIndicator(ind); } if (gen !== reloadGenRef.current) { prevBarsKey.current = ''; queueFullReload(); return; } 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) { queueFullReload(); return; } } if (mgr.getLoadedIndicatorCount() < expected) { if (reloadRetryRef.current < 6) { reloadRetryRef.current += 1; queueIndicatorRecovery(); } return; } } reloadRetryRef.current = 0; prevChartType.current = ct; prevTheme.current = th; prevLogScale.current = ls; prevBarsKey.current = barsKey(newBars, market); prevIndKey.current = indKey(inds); prevSortedPKRef.current = sortedParamKey(inds); 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 = ''; queueFullReload(); return; } 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) ); }, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market, queueFullReload]); useEffect(() => { reloadAllRef.current = reloadAll; }, [reloadAll]); // ── 차트 초기화 (마운트 시 1회) ────────────────────────────────────────── useEffect(() => { if (!containerRef.current) return; const mgr = new ChartManager(containerRef.current, theme); managerRef.current = mgr; setChartMgr(mgr); onManagerReady(mgr); const unsub = mgr.subscribeCrosshair((p: MouseEventParams