Files
goldenChart/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx
T
2026-05-30 01:28:58 +09:00

225 lines
7.1 KiB
TypeScript

/**
* 매매 시그널 알림 행 — 캔들·지표 미니 차트 카드
*/
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<Props> = ({
market,
timeframe,
theme = 'dark',
indicators = [],
enabled = true,
fillHeight = false,
onRealtimeActivity,
}) => {
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 syncChartLayout = useCallback(() => {
const wrap = canvasWrapRef.current;
const mgr = managerRef.current;
if (!wrap || !mgr?.hasMainSeries()) return;
const chartWrap = wrap.querySelector<HTMLElement>('.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 (
<div className={`tnl-mini-chart-placeholder${fillHeight ? ' tnl-mini-chart-placeholder--fill' : ''}`}>
차트 대기
</div>
);
}
return (
<div
ref={canvasWrapRef}
className={[
'tnl-mini-chart-canvas',
'slot-chart-wrap',
fillHeight ? 'tnl-mini-chart-canvas--fill' : '',
].filter(Boolean).join(' ')}
>
{isLoading && <div className="tnl-mini-chart-loading">로딩…</div>}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 12, height: 12 }} />
</div>
)}
<TradingChart
key={`${market}-${timeframe}-${chartReloadTick}-${indicators.map(i => 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}
/>
</div>
);
};
export default TradeSignalMiniChart;