import React, { memo, useMemo } from 'react'; import type { StrategyDto } from '../../utils/backendApi'; import { resolveVirtualTargetNames, } from '../../utils/virtualTargetNames'; import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import { buildConditionMetrics } from '../../utils/virtualSignalMetrics'; import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; import type { Timeframe } from '../../types'; import { useIntersectionVisible } from '../../hooks/useIntersectionVisible'; import VirtualLiveBadge from './VirtualLiveBadge'; import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage'; import VirtualTargetCardChart from './VirtualTargetCardChart'; import VirtualTargetSignalPanel from './VirtualTargetSignalPanel'; import type { Theme } from '../../types'; import type { TickerData } from '../../hooks/useMarketTicker'; import VirtualTargetQuote from './VirtualTargetQuote'; import VirtualTargetCardFoot from './VirtualTargetCardFoot'; export type VirtualCardDisplayMode = 'signal' | 'chart'; /** default: 우측 액션 버튼 · inline-quote: 우측 현재가·등락·실시간 배지 */ export type VirtualCardHeadVariant = 'default' | 'inline-quote'; interface Props { market: string; strategy: StrategyDto | undefined; strategies: StrategyDto[]; strategyId: number | null; globalStrategyId: number | null; onStrategyChange?: (strategyId: number | null) => void; snapshot: VirtualIndicatorSnapshot | undefined; signalLoading?: boolean; running: boolean; liveStatus?: VirtualLiveStatus; lastTickAt?: number; viewMode?: VirtualCardViewMode; displayMode?: VirtualCardDisplayMode; onDisplayModeChange?: (mode: VirtualCardDisplayMode) => void; onEnterFocus?: () => void; selected?: boolean; onSelect?: () => void; theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartCandleAreaPriceLabels?: boolean; chartSeriesPriceLabels?: boolean; chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; chartLiveReceiveHighlight?: boolean; ticker?: TickerData; /** 투자대상 저장 한글명·영문 심볼 (없으면 티커·마켓 캐시) */ koreanName?: string; englishName?: string; /** 알림 목록 등 — 푸터 전략 셀렉트 읽기 전용 라벨 */ readOnlyStrategyLabel?: string; /** 헤더 우측 레이아웃 (알림 목록 신호 슬롯: inline-quote) */ headVariant?: VirtualCardHeadVariant; /** gc_app_settings.liveStrategyId — ui 미동기화 시 지표 평가용 */ appLiveStrategyId?: number | null; } const VirtualTargetCard: React.FC = ({ market, strategy, strategies, strategyId, globalStrategyId, onStrategyChange, snapshot, signalLoading = false, running, liveStatus = 'idle', lastTickAt, viewMode = 'summary', displayMode = 'signal', onDisplayModeChange, onEnterFocus, selected = false, onSelect, theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartCandleAreaPriceLabels = true, chartSeriesPriceLabels = true, chartPaneSeparator, chartLiveReceiveHighlight = true, ticker, koreanName, englishName, readOnlyStrategyLabel, headVariant = 'default', appLiveStrategyId = null, }) => { const inlineHeadQuote = headVariant === 'inline-quote'; const { koreanName: displayKo, englishName: displayEn } = resolveVirtualTargetNames( market, koreanName ?? ticker?.koreanName, englishName, ); const rows = snapshot?.rows ?? []; const effectiveStrategyId = resolveVirtualTargetStrategyId( { strategyId }, globalStrategyId, appLiveStrategyId, ); const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); const metCount = metrics.filter(m => m.status === 'match').length; const evalCount = metrics.filter(m => m.status !== 'unknown').length; const isDetail = viewMode === 'detail'; const isChart = displayMode === 'chart'; const chartTimeframe = useMemo( () => resolveStrategyPrimaryTimeframe(strategy) as Timeframe, [strategy], ); const receiveSignal = useMemo(() => { if (!running) return null; return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`; }, [running, lastTickAt, snapshot?.updatedAt]); const receiving = useLiveReceiveFlash(receiveSignal, running); const highlightReceiving = chartLiveReceiveHighlight && receiving; const { ref: cardRef, visible: cardInView } = useIntersectionVisible(); const chartStreamEnabled = isChart && cardInView; return (
{ if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onSelect(); } }} role={onSelect ? 'button' : undefined} tabIndex={onSelect ? 0 : undefined} aria-pressed={onSelect ? selected : undefined} >
{isChart ? ( {displayKo} ) : ( <> {displayKo} {displayEn} )}
{inlineHeadQuote ? (
e.stopPropagation()}> {running && }
) : (
{onEnterFocus && ( )}
e.stopPropagation()}>
)}
{!inlineHeadQuote && (
{!isChart && isDetail && evalCount > 0 && ( {metCount}/{evalCount} 조건 충족 )} {running && }
)} {isChart ? ( ) : ( )}
); }; export default memo(VirtualTargetCard);