Files
goldenChart/frontend/src/components/trendSearch/TrendSearchCardChart.tsx
T
2026-06-08 15:25:44 +09:00

209 lines
7.1 KiB
TypeScript

/**
* 추세검색 카드 — 인라인 캔들 + 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 { buildChartIndicatorConfig } from '../../utils/indicatorPaneMerge';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
interface Props {
market: string;
timeframe: string;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartCandleAreaPriceLabels?: boolean;
chartSeriesPriceLabels?: boolean;
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
/** false — 뷰포트 밖: STOMP·히스토리 중지 (기본 true) */
chartStreamEnabled?: 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<Props> = ({
market,
timeframe,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartCandleAreaPriceLabels = true,
chartSeriesPriceLabels = true,
chartPaneSeparator,
chartStreamEnabled = true,
}) => {
const chartTf = toChartTimeframe(timeframe);
const { getParams, getVisualConfig } = useIndicatorSettings();
const indicators = useMemo(
() => CHART_INDICATOR_TYPES.map(type =>
buildChartIndicatorConfig(type, `${type}-tsd-card`, getParams, getVisualConfig),
).filter((c): c is NonNullable<typeof c> => c != null),
[getParams, getVisualConfig],
);
const managerRef = useRef<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const [chartReloadTick, setChartReloadTick] = useState(0);
const chartReloadTriggeredRef = useRef(false);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(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 streamActive = useUpbit && chartStreamEnabled;
const { bars, barsMarket, isLoading } = useChartRealtimeData(
market,
chartTf,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: streamActive,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, streamActive, chartRealtimeSource]),
);
const { isLoadingMore } = useHistoryLoader({
symbol: market,
timeframe: chartTf,
isUpbit: streamActive,
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 (!streamActive) return;
void pinCandleWatch(market, timeframeToCandleType(chartTf));
}, [market, chartTf, streamActive]);
return (
<div className="vtd-card-chart-panel vtd-card-chart-panel--fill">
<div
ref={canvasWrapRef}
className="vtd-card-chart-canvas slot-chart-wrap vtd-card-chart-canvas--fill"
>
{isLoading && <div className="vtd-card-chart-loading">차트 로딩…</div>}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span>과거 데이터…</span>
</div>
)}
<TradingChart
key={`${market}-${chartTf}-${chartReloadTick}`}
chartVisible={chartStreamEnabled}
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={chartTf}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={indicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
volumeVisible
showHoverToolbar={false}
candleAreaPriceLabelsEnabled={chartCandleAreaPriceLabels}
indicatorAreaPriceLabelsEnabled={chartSeriesPriceLabels}
paneSeparatorOptions={chartPaneSeparator}
/>
</div>
</div>
);
};
export default TrendSearchCardChart;