/** * 매매 시그널 알림 행 — 캔들·지표 미니 차트 카드 */ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import TradingChart from '../TradingChart'; import type { IndicatorConfig } from '../../types'; import type { ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, } from '../../types'; import { isUpbitMarket } from '../../utils/upbitApi'; import { useChartRealtimeData } from '../../hooks/useChartRealtimeData'; import { useHistoryLoader } from '../../hooks/useHistoryLoader'; import type { ChartManager } from '../../utils/ChartManager'; import { pinCandleWatch } from '../../utils/backendApi'; import { timeframeToCandleType } from '../../utils/chartCandleType'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; interface Props { market: string; timeframe: Timeframe; theme?: Theme; indicators?: IndicatorConfig[]; enabled?: boolean; /** 전체보기 모드 — 부모 높이에 맞춤 */ fillHeight?: boolean; /** STOMP/WS 실시간 봉 수신 시 (목록 행 형광 플래시 등) */ onRealtimeActivity?: () => void; } const noop = () => {}; const TradeSignalMiniChart: React.FC = ({ market, timeframe, theme = 'dark', indicators = [], enabled = true, fillHeight = false, onRealtimeActivity, }) => { const managerRef = useRef(null); const canvasWrapRef = useRef(null); const [chartReloadTick, setChartReloadTick] = useState(0); const chartReloadTriggeredRef = useRef(false); const pendingBarRef = useRef(null); const barsMarketRef = useRef(null); const chartLiveReadyRef = useRef(false); const marketRef = useRef(market); marketRef.current = market; const syncChartLayout = useCallback(() => { const wrap = canvasWrapRef.current; const mgr = managerRef.current; if (!wrap || !mgr?.hasMainSeries()) return; const chartWrap = wrap.querySelector('.tv-chart-wrap'); const w = chartWrap?.clientWidth ?? wrap.clientWidth; const h = chartWrap?.clientHeight ?? wrap.clientHeight; if (w <= 0 || h <= 0) return; mgr.syncLayout(w, h); }, []); const handleCandlesReady = useCallback(() => { chartLiveReadyRef.current = true; const pending = pendingBarRef.current; const mgr = managerRef.current; if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return; pendingBarRef.current = null; mgr.updateBar(pending); syncChartLayout(); }, [syncChartLayout]); const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { if (barsMarketRef.current !== marketRef.current) return; if (!chartLiveReadyRef.current || !managerRef.current) { pendingBarRef.current = bar; return; } if (append) managerRef.current.appendBar(bar); else managerRef.current.updateBar(bar); }, []); const handleTickUpdate = useCallback((bar: OHLCVBar) => { applyRealtimeBar(bar, false); onRealtimeActivity?.(); }, [applyRealtimeBar, onRealtimeActivity]); const handleNewCandle = useCallback((bar: OHLCVBar) => { applyRealtimeBar(bar, true); onRealtimeActivity?.(); }, [applyRealtimeBar, onRealtimeActivity]); const useUpbit = isUpbitMarket(market); const { bars, barsMarket, isLoading } = useChartRealtimeData( market, timeframe, useMemo(() => ({ onTickUpdate: handleTickUpdate, onNewCandle: handleNewCandle, enabled: enabled && useUpbit, source: 'BACKEND_STOMP' as const, }), [handleTickUpdate, handleNewCandle, enabled, useUpbit]), ); const { isLoadingMore } = useHistoryLoader({ symbol: market, timeframe, isUpbit: useUpbit && enabled, managerRef, }); barsMarketRef.current = barsMarket; const barsReady = bars.length >= 2 && barsMarket === market; useLayoutEffect(() => { chartLiveReadyRef.current = false; pendingBarRef.current = null; chartReloadTriggeredRef.current = false; }, [market, timeframe, enabled]); useEffect(() => { if (!enabled) return; const wrap = canvasWrapRef.current; if (!wrap) return; const timers = [200, 600, 1200].map(delay => setTimeout(() => { if (!canvasWrapRef.current) return; if (managerRef.current?.hasMainSeries()) { syncChartLayout(); } else if (!chartReloadTriggeredRef.current && delay >= 600 && barsReady) { chartReloadTriggeredRef.current = true; setChartReloadTick(t => t + 1); } }, delay), ); const ro = new ResizeObserver(() => syncChartLayout()); ro.observe(wrap); return () => { timers.forEach(clearTimeout); ro.disconnect(); }; }, [market, timeframe, enabled, barsReady, syncChartLayout, fillHeight]); useEffect(() => { if (!enabled || !barsReady) return; if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) { chartReloadTriggeredRef.current = true; setChartReloadTick(t => t + 1); } }, [enabled, barsReady]); useEffect(() => { if (!enabled) return; const t = setTimeout(() => syncChartLayout(), 80); return () => clearTimeout(t); }, [fillHeight, enabled, syncChartLayout]); useEffect(() => { if (!enabled || !useUpbit) return; void pinCandleWatch(market, timeframeToCandleType(timeframe)); if (timeframeToCandleType(timeframe) !== '1m') { void pinCandleWatch(market, '1m'); } }, [market, timeframe, enabled, useUpbit]); if (!enabled) { return (
차트 대기
); } return (
{isLoading &&
로딩…
} {isLoadingMore && (
)} i.id).join(',')}`} chartVisible bars={bars} barsMarket={barsMarket} market={market} timeframe={timeframe} chartType={'candlestick' as ChartType} theme={theme} mode={'chart' as ChartMode} indicators={indicators} drawingTool="cursor" drawings={[] as Drawing[]} logScale={false} drawingsLocked drawingsVisible={false} onCrosshair={noop as (d: LegendData | null) => void} onManagerReady={mgr => { managerRef.current = mgr; }} onAddDrawing={noop as (d: Drawing) => void} onCandlesReady={handleCandlesReady} magnifierEnabled={false} volumeVisible={false} showHoverToolbar={false} seriesPriceLabelsEnabled={false} paneLayoutClamp showPaneLegend={false} />
); }; export default TradeSignalMiniChart;