206 lines
6.6 KiB
TypeScript
206 lines
6.6 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 { 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<Props> = ({
|
|
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<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 { 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 (
|
|
<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}`}
|
|
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}
|
|
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TrendSearchCardChart;
|