257 lines
9.4 KiB
TypeScript
257 lines
9.4 KiB
TypeScript
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<Props> = ({
|
|
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 (
|
|
<div
|
|
ref={cardRef}
|
|
className={[
|
|
'vtd-card',
|
|
'vtd-card--signal',
|
|
running ? 'vtd-card--live' : '',
|
|
highlightReceiving ? 'vtd-card--receiving' : '',
|
|
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
|
isChart ? 'vtd-card--chart-mode' : '',
|
|
inlineHeadQuote ? 'vtd-card--head-inline-quote' : '',
|
|
selected ? 'vtd-card--selected' : '',
|
|
].filter(Boolean).join(' ')}
|
|
onClick={onSelect}
|
|
onKeyDown={e => {
|
|
if (onSelect && (e.key === 'Enter' || e.key === ' ')) {
|
|
e.preventDefault();
|
|
onSelect();
|
|
}
|
|
}}
|
|
role={onSelect ? 'button' : undefined}
|
|
tabIndex={onSelect ? 0 : undefined}
|
|
aria-pressed={onSelect ? selected : undefined}
|
|
>
|
|
<div className="vtd-card-head">
|
|
<div className="vtd-card-head-main">
|
|
<div className={`vtd-card-title${isChart ? ' vtd-card-title--chart' : ''}`}>
|
|
{isChart ? (
|
|
<span className="vtd-card-ko">{displayKo}</span>
|
|
) : (
|
|
<>
|
|
<span className="vtd-card-ko">{displayKo}</span>
|
|
<span className="vtd-card-sym">{displayEn}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{inlineHeadQuote ? (
|
|
<div className="vtd-card-head-quote" onClick={e => e.stopPropagation()}>
|
|
<VirtualTargetQuote market={market} ticker={ticker} compact showPriceLabel={false} />
|
|
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
|
</div>
|
|
) : (
|
|
<div className="vtd-card-head-actions">
|
|
{onEnterFocus && (
|
|
<button
|
|
type="button"
|
|
className="vtd-card-focus-btn"
|
|
onClick={e => { e.stopPropagation(); onEnterFocus(); }}
|
|
title="전체화면 보기 (차트 + 신호)"
|
|
aria-label="전체화면 보기"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<polyline points="5,1 1,1 1,5" />
|
|
<polyline points="9,13 13,13 13,9" />
|
|
<line x1="1" y1="1" x2="6" y2="6" />
|
|
<line x1="13" y1="13" x2="8" y2="8" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시" onClick={e => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
|
onClick={() => onDisplayModeChange?.('signal')}
|
|
title="이 종목만 신호 보기"
|
|
>
|
|
신호
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`vtd-card-mode-btn${displayMode === 'chart' ? ' vtd-card-mode-btn--on' : ''}`}
|
|
onClick={() => onDisplayModeChange?.('chart')}
|
|
title="이 종목만 차트 보기"
|
|
>
|
|
차트
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!inlineHeadQuote && (
|
|
<div className="vtd-card-meta">
|
|
<VirtualTargetQuote market={market} ticker={ticker} compact />
|
|
{!isChart && isDetail && evalCount > 0 && (
|
|
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
|
)}
|
|
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
|
</div>
|
|
)}
|
|
|
|
{isChart ? (
|
|
<VirtualTargetCardChart
|
|
market={market}
|
|
strategy={strategy}
|
|
theme={theme}
|
|
running={running}
|
|
chartRealtimeSource={chartRealtimeSource}
|
|
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
|
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
chartPaneSeparator={chartPaneSeparator}
|
|
chartTimeframe={chartTimeframe}
|
|
chartStreamEnabled={chartStreamEnabled}
|
|
/>
|
|
) : (
|
|
<VirtualTargetSignalPanel
|
|
snapshot={snapshot}
|
|
viewMode={viewMode}
|
|
receiving={highlightReceiving}
|
|
loading={signalLoading}
|
|
hasStrategy={effectiveStrategyId != null}
|
|
/>
|
|
)}
|
|
|
|
<VirtualTargetCardFoot
|
|
snapshot={snapshot}
|
|
strategies={strategies}
|
|
strategyId={strategyId}
|
|
globalStrategyId={globalStrategyId}
|
|
onStrategyChange={onStrategyChange}
|
|
readOnlyStrategyLabel={readOnlyStrategyLabel}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default memo(VirtualTargetCard);
|