매매 시그널 알림 화면 수정

This commit is contained in:
Macbook
2026-05-29 00:46:20 +09:00
parent cbad62a5b0
commit e43b5cbd5a
45 changed files with 2186 additions and 415 deletions
@@ -38,29 +38,28 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
return items;
}, [litCount, rate]);
const ticks = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
/** 신호등 열 높이에 맞춘 20% 눈금 (가상매매·알림 공통) */
const ticks = [100, 80, 60, 40, 20, 0];
return (
<div className="vtd-sig-eq">
<div className="vtd-sig-eq-scale">
<div className="vtd-sig-eq-scale" aria-hidden>
{ticks.map(t => (
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
))}
</div>
<div className="vtd-sig-eq-column">
<div className="vtd-sig-eq-bar-wrap">
<div className="vtd-sig-eq-bar">
{segments.map((seg, i) => (
<div
key={i}
className={[
'vtd-sig-eq-seg',
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
].filter(Boolean).join(' ')}
/>
))}
</div>
<div className="vtd-sig-eq-bar-wrap">
<div className="vtd-sig-eq-bar">
{segments.map((seg, i) => (
<div
key={i}
className={[
'vtd-sig-eq-seg',
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
].filter(Boolean).join(' ')}
/>
))}
</div>
{rate >= 100 && (
<div className="vtd-sig-eq-badge">100% MATCH</div>
@@ -19,6 +19,9 @@ 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;
@@ -43,6 +46,10 @@ interface Props {
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
chartLiveReceiveHighlight?: boolean;
ticker?: TickerData;
/** 알림 목록 등 — 푸터 전략 셀렉트 읽기 전용 라벨 */
readOnlyStrategyLabel?: string;
/** 헤더 우측 레이아웃 (알림 목록 신호 슬롯: inline-quote) */
headVariant?: VirtualCardHeadVariant;
}
const VirtualTargetCard: React.FC<Props> = ({
@@ -69,7 +76,10 @@ const VirtualTargetCard: React.FC<Props> = ({
chartPaneSeparator,
chartLiveReceiveHighlight = true,
ticker,
readOnlyStrategyLabel,
headVariant = 'default',
}) => {
const inlineHeadQuote = headVariant === 'inline-quote';
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
const rows = snapshot?.rows ?? [];
@@ -101,6 +111,7 @@ const VirtualTargetCard: React.FC<Props> = ({
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}
@@ -121,51 +132,60 @@ const VirtualTargetCard: React.FC<Props> = ({
<span className="vtd-card-sym">{sym}</span>
</div>
</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>
{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>
) : (
<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>
<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>
{!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
@@ -193,6 +213,7 @@ const VirtualTargetCard: React.FC<Props> = ({
strategyId={strategyId}
globalStrategyId={globalStrategyId}
onStrategyChange={onStrategyChange}
readOnlyStrategyLabel={readOnlyStrategyLabel}
/>
</div>
);
@@ -14,6 +14,8 @@ interface Props {
strategyId: number | null;
globalStrategyId: number | null;
onStrategyChange?: (strategyId: number | null) => void;
/** 변경 불가 시 셀렉트 대신 표시할 전략명 (알림 목록 등) */
readOnlyStrategyLabel?: string;
}
/** 카드 하단 — 갱신 시각(좌) · 전략 드롭다운(우) */
@@ -23,29 +25,48 @@ const VirtualTargetCardFoot: React.FC<Props> = ({
strategyId,
globalStrategyId,
onStrategyChange,
}) => (
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {formatUpdatedTime(snapshot?.updatedAt)}</span>
<div className="vtd-card-foot-controls" onClick={e => e.stopPropagation()}>
<label className="vtd-card-foot-field">
<select
className="vtd-card-foot-select"
aria-label="투자전략"
value={targetStrategySelectValue({ strategyId })}
onChange={e => {
onStrategyChange?.(parseTargetStrategySelectValue(e.target.value));
}}
>
<option value={targetStrategySelectValue({ strategyId: null })}>
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
</option>
{strategies.map(s => (
<option key={s.id} value={String(s.id)}>{s.name}</option>
))}
</select>
</label>
readOnlyStrategyLabel,
}) => {
const resolvedName =
readOnlyStrategyLabel?.trim()
|| strategies.find(s => s.id === strategyId)?.name
|| '';
return (
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {formatUpdatedTime(snapshot?.updatedAt)}</span>
<div className="vtd-card-foot-controls" onClick={e => e.stopPropagation()}>
<label className="vtd-card-foot-field">
{readOnlyStrategyLabel != null && !onStrategyChange ? (
<span
className="vtd-card-foot-select vtd-card-foot-select--readonly"
title={resolvedName}
>
{resolvedName || '전략'}
</span>
) : (
<select
className="vtd-card-foot-select"
aria-label="투자전략"
value={targetStrategySelectValue({ strategyId })}
disabled={!onStrategyChange}
title={!onStrategyChange ? resolvedName : undefined}
onChange={e => {
onStrategyChange?.(parseTargetStrategySelectValue(e.target.value));
}}
>
<option value={targetStrategySelectValue({ strategyId: null })}>
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
</option>
{strategies.map(s => (
<option key={s.id} value={String(s.id)}>{s.name}</option>
))}
</select>
)}
</label>
</div>
</div>
</div>
);
);
};
export default VirtualTargetCardFoot;
@@ -27,9 +27,11 @@ interface Props {
ticker?: TickerData;
/** 카드 메타 행 — 현재가만 간략 표시 */
compact?: boolean;
/** compact 시 "현재가" 라벨 표시 (기본 true) */
showPriceLabel?: boolean;
}
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false }) => {
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false, showPriceLabel = true }) => {
const code = market.replace(/^KRW-/, '');
const change: ChangeDir = ticker?.change ?? 'EVEN';
const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even';
@@ -54,7 +56,7 @@ const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false }
if (compact) {
return (
<div className={`vtd-target-quote vtd-target-quote--compact ${flashClass}`.trim()} aria-label="현재가">
<span className="vtd-card-price-label"></span>
{showPriceLabel && <span className="vtd-card-price-label"></span>}
<span className={`vtd-target-price ${colorCls}`}>
{ticker ? fmtKrw(ticker.tradePrice) : '-'}
</span>
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import {
buildConditionMetrics,
@@ -36,6 +37,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
[metrics, snapshot?.matchRate],
);
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
const { visualRef, lightRef } = useSignalVisualRailHeight([
rows.length,
matchRate,
trafficState,
viewMode,
]);
if (rows.length === 0) {
return (
@@ -58,12 +65,18 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
>
<div className={`vtd-sig-panel${isDetail ? ' vtd-sig-panel--detail-top' : ' vtd-sig-panel--summary'}`}>
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div>
<div className="vtd-sig-visual">
<div className="vtd-sig-visual-eq">
<div className="vtd-sig-visual" ref={visualRef}>
<div className="vtd-sig-visual-pane vtd-sig-visual-eq">
<VirtualSignalEqualizer matchRate={matchRate} />
</div>
<VirtualConditionList metrics={metrics} />
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
<div className="vtd-sig-visual-pane vtd-sig-visual-heat">
<VirtualConditionList metrics={metrics} />
</div>
<div className="vtd-sig-visual-pane vtd-sig-visual-light">
<div ref={lightRef} className="vtd-sig-light-rail">
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
</div>
</div>
</div>
</div>
{isDetail && (