/** * 추세검색 카드 — 인라인 캔들 + RSI/MACD/BB */ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import TradingChart from '../TradingChart'; import { pinCandleWatch } from '../../utils/backendApi'; import type { ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, } from '../../types'; import { isUpbitMarket } from '../../utils/upbitApi'; import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import { useHistoryLoader } from '../../hooks/useHistoryLoader'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import type { ChartManager } from '../../utils/ChartManager'; import { timeframeToCandleType } from '../../utils/chartCandleType'; import { enrichIndicatorConfig } from '../../utils/indicatorRegistry'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; interface Props { market: string; timeframe: string; theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; } const noop = () => {}; const CHART_INDICATOR_TYPES = ['BollingerBands', 'RSI', 'MACD'] as const; function toChartTimeframe(tf: string): Timeframe { if (tf === '1d') return '1D'; if (tf === '1w') return '1W'; if (tf === '1M') return '1M'; return tf as Timeframe; } const TrendSearchCardChart: React.FC = ({ market, timeframe, theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, }) => { const chartTf = toChartTimeframe(timeframe); const { getParams, getVisualConfig } = useIndicatorSettings(); const indicators = useMemo( () => CHART_INDICATOR_TYPES.map(type => { const base = enrichIndicatorConfig({ id: `${type}-tsd-card`, type, params: { ...getParams(type) }, }); return { ...base, plots: getVisualConfig(type)?.plots ?? base.plots, }; }), [getParams, getVisualConfig], ); 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 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); }, []); 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), [applyRealtimeBar]); const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]); const useUpbit = isUpbitMarket(market); const { bars, barsMarket, isLoading } = useChartRealtimeData( market, chartTf, useMemo(() => ({ onTickUpdate: handleTickUpdate, onNewCandle: handleNewCandle, enabled: useUpbit, source: chartRealtimeSource, }), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), ); const { isLoadingMore } = useHistoryLoader({ symbol: market, timeframe: chartTf, isUpbit: useUpbit, managerRef, }); barsMarketRef.current = barsMarket; useLayoutEffect(() => { chartLiveReadyRef.current = false; pendingBarRef.current = null; chartReloadTriggeredRef.current = false; }, [market, chartTf]); useEffect(() => { const wrap = canvasWrapRef.current; if (!wrap) return; const applyPaneHeights = () => { const h = wrap.clientHeight; if (h <= 0) return; const mgr = managerRef.current; if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h); }; const timers = [100, 300, 800, 1500].map(delay => setTimeout(() => { if (!canvasWrapRef.current) return; const h = canvasWrapRef.current.clientHeight; if (h <= 0) return; if (managerRef.current?.hasMainSeries()) managerRef.current.resetPaneHeights(h); else if (!chartReloadTriggeredRef.current && delay >= 800 && bars.length > 0) { chartReloadTriggeredRef.current = true; setChartReloadTick(t => t + 1); } }, delay), ); const ro = new ResizeObserver(() => applyPaneHeights()); ro.observe(wrap); return () => { timers.forEach(clearTimeout); ro.disconnect(); }; }, [market, chartTf, bars.length]); useEffect(() => { if (!useUpbit) return; void pinCandleWatch(market, timeframeToCandleType(chartTf)); }, [market, chartTf, useUpbit]); return (
{isLoading &&
차트 로딩…
} {isLoadingMore && (
과거 데이터…
)} void} onManagerReady={mgr => { managerRef.current = mgr; }} onAddDrawing={noop as (d: Drawing) => void} onCandlesReady={handleCandlesReady} magnifierEnabled={false} volumeVisible showHoverToolbar={false} seriesPriceLabelsEnabled={chartSeriesPriceLabels} />
); }; export default TrendSearchCardChart;