매매 시그널 알림 화면 수정

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
@@ -0,0 +1,64 @@
/**
* 캔들 pane 하단 시간축 — 거래량·보조지표가 아래에 있을 때 캔들 영역 바로 아래에도 시간 표시.
*/
import React, { useState, useEffect, useCallback } from 'react';
import type { ChartManager } from '../utils/ChartManager';
const AXIS_HEIGHT = 22;
interface CandlePaneTimeAxisProps {
manager: ChartManager;
containerEl: HTMLElement;
}
const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, containerEl }) => {
const [state, setState] = useState<ReturnType<ChartManager['getCandlePaneTimeAxisOverlay']>>(null);
const sync = useCallback(() => {
if (!containerEl.isConnected) return;
setState(manager.getCandlePaneTimeAxisOverlay());
}, [manager, containerEl]);
useEffect(() => {
sync();
const ro = new ResizeObserver(sync);
ro.observe(containerEl);
const unsubLayout = manager.subscribePaneLayout(sync);
const unsubRange = manager.subscribeViewport(sync);
const timers = [0, 50, 150, 400].map(ms => window.setTimeout(sync, ms));
return () => {
ro.disconnect();
unsubLayout();
unsubRange();
timers.forEach(clearTimeout);
};
}, [manager, containerEl, sync]);
if (!state || state.labels.length === 0) return null;
return (
<div
className="candle-pane-time-axis"
style={{
top: state.topY,
height: state.height ?? AXIS_HEIGHT,
width: state.plotWidth,
}}
aria-hidden
>
{state.labels.map((label, idx) => (
<span
key={`${label.x}-${idx}`}
className="candle-pane-time-axis__label"
style={{ left: label.x }}
>
{label.text}
</span>
))}
</div>
);
};
export default CandlePaneTimeAxis;
+2
View File
@@ -27,6 +27,7 @@ import { invalidateMarketCache } from '../utils/requestCache';
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import { markChartIndicatorSaveCommitted } from '../utils/indicatorChartDefaultsSync';
import {
duplicateIndicatorInList,
mergeIndicators,
@@ -568,6 +569,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
setSettingsModalId(null);
setCtxToolbar(null);
markChartIndicatorSaveCommitted();
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
saveParams(updated.type, updated.params);
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
@@ -0,0 +1,91 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CHART_TIME_FORMAT_PRESETS,
formatUnixWithChartPattern,
isPresetChartTimeFormat,
normalizeChartTimeFormat,
} from '../utils/chartTimeFormat';
const CUSTOM_VALUE = '__custom__';
interface ChartTimeFormatPickerProps {
value: string;
onChange: (format: string) => void;
}
const ChartTimeFormatPicker: React.FC<ChartTimeFormatPickerProps> = ({ value, onChange }) => {
const normalized = normalizeChartTimeFormat(value);
const isPreset = isPresetChartTimeFormat(normalized);
const [selectValue, setSelectValue] = useState(
isPreset ? normalized : CUSTOM_VALUE,
);
const [customText, setCustomText] = useState(
isPreset ? '' : normalized,
);
useEffect(() => {
const n = normalizeChartTimeFormat(value);
if (isPresetChartTimeFormat(n)) {
setSelectValue(n);
setCustomText('');
} else {
setSelectValue(CUSTOM_VALUE);
setCustomText(n);
}
}, [value]);
const preview = useMemo(() => {
const fmt = selectValue === CUSTOM_VALUE
? normalizeChartTimeFormat(customText)
: selectValue;
return formatUnixWithChartPattern(Math.floor(Date.now() / 1000), fmt);
}, [selectValue, customText]);
const commitCustom = useCallback(() => {
const next = normalizeChartTimeFormat(customText);
onChange(next);
}, [customText, onChange]);
return (
<div className="stg-chart-time-format">
<select
className="stg-select"
value={selectValue}
onChange={e => {
const v = e.target.value;
setSelectValue(v);
if (v !== CUSTOM_VALUE) {
onChange(v);
} else if (customText.trim()) {
onChange(normalizeChartTimeFormat(customText));
}
}}
>
{CHART_TIME_FORMAT_PRESETS.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
<option value={CUSTOM_VALUE}> </option>
</select>
{selectValue === CUSTOM_VALUE && (
<input
type="text"
className="stg-input stg-chart-time-format-custom"
placeholder="예: yyyy-MM-dd HH:mm"
value={customText}
onChange={e => setCustomText(e.target.value)}
onBlur={commitCustom}
onKeyDown={e => { if (e.key === 'Enter') commitCustom(); }}
/>
)}
<span className="stg-hint stg-chart-time-format-preview" title="현재 시각 미리보기">
: {preview}
</span>
<span className="stg-hint">
토큰: yyyy, yy, MM, dd, HH, mm, ss
</span>
</div>
);
};
export default ChartTimeFormatPicker;
+23
View File
@@ -43,6 +43,7 @@ import {
LINE_WIDTH_OPTIONS,
} from '../utils/plotColorUtils';
import TimezonePicker from './TimezonePicker';
import ChartTimeFormatPicker from './ChartTimeFormatPicker';
import AdminPasswordGate from './AdminPasswordGate';
import AdminSettingsPanel from './AdminSettingsPanel';
import {
@@ -152,6 +153,8 @@ interface SettingsPageProps {
onFcmTest?: () => void;
displayTimezone?: string;
onDisplayTimezoneChange?: (tz: string) => void;
chartTimeFormat?: string;
onChartTimeFormatChange?: (format: string) => void;
menuPermissions?: Record<string, boolean>;
verificationIssueNotify?: boolean;
onVerificationIssueNotify?: (v: boolean) => void;
@@ -651,6 +654,8 @@ interface ChartPanelProps {
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
chartPaneSeparator?: ChartPaneSeparatorOptions;
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
chartTimeFormat?: string;
onChartTimeFormatChange?: (format: string) => void;
}
const ChartPanel: React.FC<ChartPanelProps> = ({
@@ -667,6 +672,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
onChartLegendOptionsChange,
chartPaneSeparator,
onChartPaneSeparatorChange,
chartTimeFormat = 'MM-dd HH:mm',
onChartTimeFormatChange,
}) => {
const [upColor, setUp] = useState('#26a69a');
const [downColor, setDown] = useState('#ef5350');
@@ -678,6 +685,18 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
return (
<>
<SettingSection title="시간축 표시">
<SettingRow
label="시간 포맷"
desc="차트 하단 시간축·크로스헤어에 표시되는 날짜·시간 형식입니다. 프리셋을 선택하거나 직접 입력할 수 있습니다."
>
<ChartTimeFormatPicker
value={chartTimeFormat}
onChange={fmt => onChartTimeFormatChange?.(fmt)}
/>
</SettingRow>
</SettingSection>
<SettingSection title="실시간 차트 데이터">
<SettingRow label="데이터 소스" desc="Blueprint 권장: 백엔드 STOMP. 직연결은 업비트 WebSocket을 프론트가 직접 수신합니다.">
<select className="stg-select" value={chartRealtimeSource}
@@ -1577,6 +1596,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onFcmTest,
displayTimezone = 'Asia/Seoul',
onDisplayTimezoneChange,
chartTimeFormat = 'MM-dd HH:mm',
onChartTimeFormatChange,
menuPermissions,
verificationIssueNotify = true,
onVerificationIssueNotify,
@@ -1626,6 +1647,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onChartLegendOptionsChange={onChartLegendOptionsChange}
chartPaneSeparator={chartPaneSeparator}
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
chartTimeFormat={chartTimeFormat}
onChartTimeFormatChange={onChartTimeFormatChange}
/>
);
case 'indicators':
+1
View File
@@ -162,6 +162,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'notifications', label: '알림목록', icon: <IcNotify /> },
{ page: 'settings', label: '설정', icon: <IcSettings /> },
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
];
@@ -12,6 +12,8 @@ import { OrderbookPanel } from './OrderbookPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import { useAppSettings } from '../hooks/useAppSettings';
import '../styles/strategyEditorTheme.css';
import '../styles/virtualTradingDashboard.css';
import '../styles/tradeNotificationList.css';
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
@@ -95,6 +97,8 @@ export const TradeNotificationListPage: React.FC<Props> = ({
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
const { settings: appSettings } = useAppSettings();
const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true;
const refreshPaperData = useCallback(async () => {
try {
@@ -205,43 +209,16 @@ export const TradeNotificationListPage: React.FC<Props> = ({
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
return (
<div className={`tnl-page tnl-page--with-right bps-page--vtd app ${theme}`}>
<div className={`tnl-page tnl-page--with-right bps-page--vtd se-page se-page--${theme} app ${theme}`}>
<div className="tnl-body">
<div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
<div className="tnl-header">
<div className="tnl-header-row">
<div className="tnl-header-intro">
<h1 className="tnl-title"> </h1>
<p className="tnl-sub">
{isListView
? '각 알림 왼쪽은 요약, 오른쪽은 캔들·지표 차트입니다. 차트 영역은 가로 스크롤로 확인할 수 있습니다.'
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 우측 아이콘으로 배치를 변경할 수 있습니다.`}
</p>
</div>
<div className="tnl-header-actions">
<div className="tnl-layout-toggle vtd-view-toggle" role="group" aria-label="목록 표시 방식">
<button
type="button"
className={`vtd-view-toggle-btn${isListView ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => handleListLayoutChange('list')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${!isListView ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => handleListLayoutChange('grid')}
>
</button>
</div>
<TradeNotificationGridLayoutPicker
value={gridPreset}
onChange={handleGridPresetChange}
disabled={isListView}
/>
</div>
</div>
<h1 className="tnl-title"> </h1>
<p className="tnl-sub">
{isListView
? '각 알림 왼쪽은 가상매매와 동일한 신호 상세 카드, 오른쪽은 캔들·지표 차트입니다. 영역 우측 버튼으로 가로 스크롤할 수 있습니다.'
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 툴바 우측에서 배치를 변경할 수 있습니다.`}
</p>
<div className="tnl-toolbar">
<span className="tnl-chip tnl-chip--warn"> {unreadCount}</span>
<div className="tnl-filter">
@@ -268,27 +245,50 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</button>
)}
<span className="tnl-toolbar-divider" aria-hidden="true" />
<button
type="button"
className="tnl-icon-btn"
title="미확인 알림 삭제"
aria-label="미확인 알림 삭제"
disabled={unreadCount === 0}
onClick={() => void handleDeleteUnread()}
>
<IcTrash />
</button>
<button
type="button"
className="tnl-icon-btn tnl-icon-btn--danger"
title="모두 삭제"
aria-label="모두 삭제"
disabled={allNotifications.length === 0}
onClick={() => void handleDeleteAll()}
>
<IcTrash />
</button>
<div className="tnl-toolbar-end">
<div className="tnl-layout-toggle vtd-view-toggle" role="group" aria-label="목록 표시 방식">
<button
type="button"
className={`vtd-view-toggle-btn${isListView ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => handleListLayoutChange('list')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${!isListView ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => handleListLayoutChange('grid')}
>
</button>
</div>
<TradeNotificationGridLayoutPicker
value={gridPreset}
onChange={handleGridPresetChange}
disabled={isListView}
/>
<span className="tnl-toolbar-divider" aria-hidden="true" />
<button
type="button"
className="tnl-icon-btn"
title="미확인 알림 삭제"
aria-label="미확인 알림 삭제"
disabled={unreadCount === 0}
onClick={() => void handleDeleteUnread()}
>
<IcTrash />
</button>
<button
type="button"
className="tnl-icon-btn tnl-icon-btn--danger"
title="모두 삭제"
aria-label="모두 삭제"
disabled={allNotifications.length === 0}
onClick={() => void handleDeleteAll()}
>
<IcTrash />
</button>
</div>
</div>
</div>
@@ -312,6 +312,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
layoutMode={listLayout}
isSelected={selectedNotifyId === item.id}
chartsEnabled={isListView}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
onSelect={() => handleNotificationSelect(item)}
onDelete={e => void handleDeleteOne(item, e)}
onDetail={() => {
@@ -322,6 +323,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
markAsRead(item.id);
onGoToChart(item.market);
}}
tickers={tickers}
/>
))}
</ul>
+61 -4
View File
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from
import type { MouseEventParams, Time } from 'lightweight-charts';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { useChartTimeFormat } from '../utils/chartTimeFormat';
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
@@ -45,6 +46,7 @@ function canApplyChartBars(
}
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
import ChartHoverToolbar from './ChartHoverToolbar';
import ChartMagnifier from './ChartMagnifier';
import ChartContextMenu from './ChartContextMenu';
@@ -216,6 +218,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
const indicatorSyncInFlightRef = useRef(false);
const pendingIndicatorResyncRef = useRef(false);
const indicatorReloadGenRef = useRef(0);
const prevMarket = useRef<string>(market);
const prevMarketTf = useRef<Timeframe>(timeframe);
@@ -228,6 +231,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const resizeDebounceRef = useRef<number | null>(null);
const chartVisibleRef = useRef(chartVisible);
const chartTimeFormat = useChartTimeFormat();
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
@@ -456,6 +460,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
});
}, [afterIndicatorPaneMutation, restoreLogicalRange]);
/** 설정 저장 등으로 연속 갱신이 겹칠 때 마지막 상태로 한 번 더 동기화 */
const flushPendingIndicatorResync = useCallback((
mgr: ChartManager,
completedGen: number,
) => {
if (!pendingIndicatorResyncRef.current) return;
if (completedGen !== indicatorReloadGenRef.current) return;
pendingIndicatorResyncRef.current = false;
const latest = indicatorsRef.current;
indicatorSyncInFlightRef.current = true;
const flushGen = ++indicatorReloadGenRef.current;
reloadIndicatorsWithCover(mgr, latest, () => {
if (flushGen !== indicatorReloadGenRef.current) {
indicatorSyncInFlightRef.current = false;
return;
}
syncIndicatorTrackingRefs(latest);
indicatorSyncInFlightRef.current = false;
});
}, [reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);
const queueFullReload = useCallback(() => {
if (!chartVisibleRef.current) return;
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
@@ -573,7 +598,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
}
mgr.setTheme(th);
mgr.setLogScale(ls);
mgr.setDisplayTimezone(displayTimezone, timeframe);
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
commitCandleLayout(mgr);
onCandlesReady?.();
lastAppliedMarketRef.current = market;
@@ -996,8 +1021,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
}, []);
useEffect(() => {
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
}, [displayTimezone, timeframe]);
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
}, [displayTimezone, timeframe, chartTimeFormat]);
// 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
useEffect(() => {
@@ -1083,6 +1108,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevLogScale.current = logScale;
mgr.setLogScale(logScale);
} else if (indChanged) {
if (indicatorSyncInFlightRef.current) {
pendingIndicatorResyncRef.current = true;
return;
}
const prevList = prevIndicatorsListRef.current;
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
const currPK = paramKey(indicators);
@@ -1142,12 +1171,18 @@ const TradingChart: React.FC<TradingChartProps> = ({
&& addedConfigs.length === 0
) {
const savedLR = mgr.getVisibleLogicalRange();
const syncGen = ++indicatorReloadGenRef.current;
indicatorSyncInFlightRef.current = true;
void mgr.refreshIndicators(paramsChangedConfigs).then(() => {
if (syncGen !== indicatorReloadGenRef.current) {
indicatorSyncInFlightRef.current = false;
return;
}
afterIndicatorPaneMutation(mgr);
restoreLogicalRange(mgr, savedLR);
syncIndicatorTrackingRefs(indicators);
indicatorSyncInFlightRef.current = false;
flushPendingIndicatorResync(mgr, syncGen);
});
} else if (isReorderOnly) {
reloadIndicatorsWithCover(mgr, indicators, () => {
@@ -1165,7 +1200,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators]);
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators, flushPendingIndicatorResync]);
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
useEffect(() => {
@@ -1325,6 +1360,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
onClose={() => setCtxMenu(null)}
/>
)}
{chartMgr && (
<CandlePaneTimeAxisPortal
manager={chartMgr}
getContainer={() => containerRef.current}
/>
)}
{chartMgr && paneSeparatorOptions && (
<PaneSeparatorOverlay
manager={chartMgr}
@@ -1397,6 +1438,22 @@ const TradingChart: React.FC<TradingChartProps> = ({
);
};
const CandlePaneTimeAxisPortal: React.FC<{
manager: ChartManager;
getContainer: () => HTMLElement | null;
}> = ({ manager, getContainer }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
const c = getContainer();
if (c) { setEl(c); return; }
const tid = setTimeout(() => setEl(getContainer()), 100);
return () => clearTimeout(tid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!el) return null;
return <CandlePaneTimeAxis manager={manager} containerEl={el} />;
};
const CandlePaneControlsPortal: React.FC<{
manager: ChartManager;
getContainer: () => HTMLElement | null;
@@ -0,0 +1,108 @@
/**
* 매매 시그널 알림 — 가로 스크롤 영역 + 우측 세로 레일(◀ ▶) 스크롤 버튼
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
const SCROLL_STEP_RATIO = 0.85;
const IcChevronLeft = () => (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="7 2 3 6 7 10" />
</svg>
);
const IcChevronRight = () => (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="5 2 9 6 5 10" />
</svg>
);
export interface TradeNotificationHScrollPaneProps {
className?: string;
/** 스크린리더용 영역 설명 */
label: string;
children: React.ReactNode;
}
const TradeNotificationHScrollPane: React.FC<TradeNotificationHScrollPaneProps> = ({
className = '',
label,
children,
}) => {
const viewportRef = useRef<HTMLDivElement>(null);
const [canScroll, setCanScroll] = useState(false);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(true);
const syncScrollState = useCallback(() => {
const el = viewportRef.current;
if (!el) return;
const max = Math.max(0, el.scrollWidth - el.clientWidth);
const overflow = max > 2;
setCanScroll(overflow);
setAtStart(!overflow || el.scrollLeft <= 2);
setAtEnd(!overflow || el.scrollLeft >= max - 2);
}, []);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
syncScrollState();
const ro = new ResizeObserver(() => syncScrollState());
ro.observe(el);
const mo = new MutationObserver(() => syncScrollState());
mo.observe(el, { childList: true, subtree: true });
return () => {
ro.disconnect();
mo.disconnect();
};
}, [syncScrollState, children]);
const scrollByStep = useCallback((dir: -1 | 1) => {
const el = viewportRef.current;
if (!el) return;
const step = Math.max(120, Math.round(el.clientWidth * SCROLL_STEP_RATIO));
el.scrollBy({ left: dir * step, behavior: 'smooth' });
}, []);
return (
<div className={['tnl-hscroll-pane', className].filter(Boolean).join(' ')}>
<div
ref={viewportRef}
className="tnl-hscroll-pane__viewport"
aria-label={label}
onScroll={syncScrollState}
>
<div className="tnl-hscroll-pane__content">
{children}
</div>
</div>
{canScroll && (
<div className="tnl-hscroll-pane__rail" role="group" aria-label={`${label} 좌우 이동`}>
<button
type="button"
className="tnl-hscroll-pane__rail-btn"
disabled={atStart}
title="왼쪽으로"
aria-label="왼쪽으로 스크롤"
onClick={e => { e.stopPropagation(); scrollByStep(-1); }}
>
<IcChevronLeft />
</button>
<button
type="button"
className="tnl-hscroll-pane__rail-btn"
disabled={atEnd}
title="오른쪽으로"
aria-label="오른쪽으로 스크롤"
onClick={e => { e.stopPropagation(); scrollByStep(1); }}
>
<IcChevronRight />
</button>
</div>
)}
</div>
);
};
export default TradeNotificationHScrollPane;
@@ -1,10 +1,13 @@
/**
* 매매 시그널 알림 목록 행 — 좌: 요약 카드(고정) · 우: 캔들+지표 카드(가로 스크롤)
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Theme } from '../../types';
import type { TickerData } from '../../hooks/useMarketTicker';
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
import type { StrategyDto } from '../../utils/backendApi';
import { loadStrategy } from '../../utils/backendApi';
import { loadStrategies, loadStrategy } from '../../utils/backendApi';
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
import {
buildSignalDetailRows,
@@ -20,6 +23,8 @@ import {
import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import TradeSignalChartCard from './TradeSignalChartCard';
import TradeNotificationSignalCard from './TradeNotificationSignalCard';
import TradeNotificationHScrollPane from './TradeNotificationHScrollPane';
const IcTrash = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
@@ -38,6 +43,8 @@ interface Props {
isSelected: boolean;
layoutMode?: TradeNotificationRowLayout;
chartsEnabled?: boolean;
chartLiveReceiveHighlight?: boolean;
tickers?: Map<string, TickerData>;
onSelect: () => void;
onDelete: (e: React.MouseEvent) => void;
onDetail: () => void;
@@ -52,6 +59,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
isSelected,
layoutMode = 'list',
chartsEnabled = true,
chartLiveReceiveHighlight = true,
tickers,
onSelect,
onDelete,
onDetail,
@@ -71,6 +80,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
const { getParams, getVisualConfig } = useIndicatorSettings();
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const strategyLabel = item.strategyName?.trim()
|| strategy?.name
|| strategyRow?.value
@@ -81,30 +91,36 @@ const TradeNotificationListRow: React.FC<Props> = ({
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
useEffect(() => {
if (!isListLayout) return;
const el = rowRef.current;
if (!el) return;
const io = new IntersectionObserver(
entries => {
if (entries[0]?.isIntersecting) setInView(true);
setInView(entries[0]?.isIntersecting ?? false);
},
{ rootMargin: '120px 0px', threshold: 0.05 },
);
io.observe(el);
return () => io.disconnect();
}, [isListLayout]);
}, [item.id]);
const panelActive = isListLayout && inView;
useEffect(() => {
if (!isListLayout || !item.strategyId || !chartsEnabled) {
if (!panelActive || !isListLayout) {
setStrategy(undefined);
setStrategies([]);
return;
}
let cancelled = false;
void loadStrategies().then(list => {
if (!cancelled) setStrategies(list ?? []);
});
if (!item.strategyId) return () => { cancelled = true; };
void loadStrategy(item.strategyId).then(s => {
if (!cancelled && s) setStrategy(s);
});
return () => { cancelled = true; };
}, [item.strategyId, chartsEnabled, isListLayout]);
}, [item.strategyId, panelActive, isListLayout]);
useEffect(() => {
setExpandedChartKey(null);
@@ -121,10 +137,66 @@ const TradeNotificationListRow: React.FC<Props> = ({
}));
}, [strategy, getParams, getVisualConfig, isListLayout]);
const chartActive = isListLayout && chartsEnabled && inView;
const chartExpanded = isListLayout && expandedChartKey != null;
const chartActive = isListLayout && chartsEnabled && panelActive;
const signalActive = isListLayout && panelActive && !chartExpanded;
const ticker = tickers?.get(item.market);
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
const signalSnapshotEnabled = isListLayout && panelActive && item.strategyId != null;
const { snapshot, loading: signalLoading } = useTradeNotificationSignalSnapshot(
item.market,
item.strategyId,
strategy,
signalSnapshotEnabled,
);
const [chartActivitySeq, setChartActivitySeq] = useState(0);
const bumpChartActivity = useCallback(() => {
setChartActivitySeq(n => n + 1);
}, []);
const liveReceiveEnabled = panelActive && chartLiveReceiveHighlight;
const receiveSignal = useMemo(() => {
if (!liveReceiveEnabled) return null;
return [
ticker?.tradePrice ?? '',
ticker?.changeRate ?? '',
snapshot?.updatedAt ?? 0,
chartActivitySeq,
].join('|');
}, [
liveReceiveEnabled,
ticker?.tradePrice,
ticker?.changeRate,
snapshot?.updatedAt,
chartActivitySeq,
]);
const receiving = useLiveReceiveFlash(receiveSignal, liveReceiveEnabled);
/** 차트 영역 카드 수 — 알림상세(1) : 차트카드(N) 동일 비율 배분용 */
const chartCardCount = useMemo(() => {
if (!isListLayout) return 0;
if (chartExpanded) return 1;
let n = 1;
if (signalActive) n += 1;
n += indicatorCards.length;
if (item.strategyId != null && indicatorCards.length === 0 && chartActive) n += 1;
return n;
}, [
isListLayout,
chartExpanded,
signalActive,
indicatorCards.length,
item.strategyId,
chartActive,
]);
const rowGalleryStyle = useMemo(
() => ({ '--tnl-chart-card-count': String(chartCardCount) }) as React.CSSProperties,
[chartCardCount],
);
const summaryCard = (
<article
className={[
@@ -223,6 +295,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
'tnl-row--grid',
!item.isRead ? 'tnl-row--unread' : '',
isSelected ? 'tnl-row--selected' : '',
receiving ? 'tnl-row--receiving' : '',
].filter(Boolean).join(' ')}
>
{summaryCard}
@@ -238,45 +311,77 @@ const TradeNotificationListRow: React.FC<Props> = ({
'tnl-row--gallery',
!item.isRead ? 'tnl-row--unread' : '',
isSelected ? 'tnl-row--selected' : '',
receiving ? 'tnl-row--receiving' : '',
].filter(Boolean).join(' ')}
>
<div className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}>
{summaryCard}
<div
className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}
style={rowGalleryStyle}
>
<TradeNotificationHScrollPane
className="tnl-row-gallery-detail"
label="알림 상세"
>
{summaryCard}
</TradeNotificationHScrollPane>
{chartExpanded ? (
<div className="tnl-charts-expanded" aria-label="차트 전체보기">
{expandedChartKey === 'candle' && (
<TradeSignalChartCard
label="캔들"
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={[]}
enabled={chartActive}
expanded
onExpand={() => setExpandedChartKey('candle')}
onRestore={() => setExpandedChartKey(null)}
/>
)}
{expandedIndicator && (
<TradeSignalChartCard
label={expandedIndicator.label}
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={expandedIndicator.config}
enabled={chartActive}
expanded
onExpand={() => setExpandedChartKey(expandedIndicator.id)}
onRestore={() => setExpandedChartKey(null)}
/>
)}
</div>
) : (
<div className="tnl-charts-scroll" aria-label="캔들·지표 차트">
<TradeNotificationHScrollPane
className={['tnl-row-gallery-charts', chartExpanded ? 'tnl-row-gallery-charts--expanded' : ''].filter(Boolean).join(' ')}
label={chartExpanded ? '차트 전체보기' : '신호·캔들·지표'}
>
{chartExpanded ? (
<div className="tnl-charts-expanded-inner">
{expandedChartKey === 'candle' && (
<TradeSignalChartCard
label="캔들"
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={[]}
enabled={chartActive}
expanded
onExpand={() => setExpandedChartKey('candle')}
onRestore={() => setExpandedChartKey(null)}
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
/>
)}
{expandedIndicator && (
<TradeSignalChartCard
label={expandedIndicator.label}
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={expandedIndicator.config}
enabled={chartActive}
expanded
onExpand={() => setExpandedChartKey(expandedIndicator.id)}
onRestore={() => setExpandedChartKey(null)}
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
/>
)}
</div>
) : (
<div className="tnl-charts-track">
{signalActive && (
<TradeNotificationSignalCard
market={item.market}
strategyId={item.strategyId}
strategy={strategy}
strategies={strategies}
strategyLabel={strategyLabel}
meta={strategyLabel}
candleType={item.candleType}
theme={theme}
ticker={ticker}
enabled={signalActive}
snapshot={snapshot}
signalLoading={signalLoading}
onExpandCharts={() => setExpandedChartKey('candle')}
/>
)}
<TradeSignalChartCard
label="캔들"
meta={candleKo}
@@ -288,6 +393,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
expanded={false}
onExpand={() => setExpandedChartKey('candle')}
onRestore={() => setExpandedChartKey(null)}
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
/>
{indicatorCards.map(card => (
@@ -303,6 +409,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
expanded={false}
onExpand={() => setExpandedChartKey(card.id)}
onRestore={() => setExpandedChartKey(null)}
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
/>
))}
@@ -312,8 +419,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
</section>
)}
</div>
</div>
)}
)}
</TradeNotificationHScrollPane>
</div>
</li>
);
@@ -0,0 +1,37 @@
/**
* 매매 시그널 알림 — 가상매매 VirtualTargetCard(신호 패널) 슬롯
*/
import React from 'react';
import type { TickerData } from '../../hooks/useMarketTicker';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import type { StrategyDto } from '../../utils/backendApi';
import type { Theme } from '../../types';
import TradeNotificationSignalSummary from './TradeNotificationSignalSummary';
export interface TradeNotificationSignalCardProps {
market: string;
strategyId: number | null | undefined;
strategy?: StrategyDto;
strategies?: StrategyDto[];
strategyLabel: string;
meta?: string;
candleType?: string;
theme?: Theme;
ticker?: TickerData;
enabled?: boolean;
snapshot?: VirtualIndicatorSnapshot;
signalLoading?: boolean;
onExpandCharts?: () => void;
}
const TradeNotificationSignalCard: React.FC<TradeNotificationSignalCardProps> = props => (
<div
className="tnl-signal-slot"
aria-label="실시간 신호 현황"
onClick={e => e.stopPropagation()}
>
<TradeNotificationSignalSummary embedded {...props} />
</div>
);
export default TradeNotificationSignalCard;
@@ -0,0 +1,102 @@
/**
* 매매 시그널 알림 목록 — 가상매매 종목 카드(요약·신호)와 동일 UI · 실시간 신호 갱신
*/
import React, { useMemo, useState } from 'react';
import type { TickerData } from '../../hooks/useMarketTicker';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
import type { StrategyDto } from '../../utils/backendApi';
import type { Theme } from '../../types';
import VirtualTargetCard, { type VirtualCardDisplayMode } from '../virtual/VirtualTargetCard';
interface Props {
market: string;
strategyId: number | null | undefined;
strategy?: StrategyDto;
strategies?: StrategyDto[];
globalStrategyId?: number | null;
candleType?: string;
theme?: Theme;
ticker?: TickerData;
enabled?: boolean;
/** tnl-chart-card 본문에 삽입 */
embedded?: boolean;
/** 부모에서 스냅샷 공유 시(행 테두리 플래시와 중복 폴링 방지) */
snapshot?: VirtualIndicatorSnapshot;
signalLoading?: boolean;
/** 차트 영역 펼쳐보기 (헤더 확대 버튼) */
onExpandCharts?: () => void;
/** 푸터 전략명 (읽기 전용) */
strategyLabel?: string;
}
const TradeNotificationSignalSummary: React.FC<Props> = ({
market,
strategyId,
strategy,
strategies = [],
globalStrategyId = null,
theme = 'dark',
ticker,
enabled = true,
embedded = false,
snapshot: snapshotProp,
signalLoading: signalLoadingProp,
onExpandCharts,
strategyLabel,
}) => {
const active = enabled && strategyId != null;
const [displayMode, setDisplayMode] = useState<VirtualCardDisplayMode>('signal');
const internal = useTradeNotificationSignalSnapshot(
market,
strategyId,
strategy,
active && snapshotProp === undefined,
);
const snapshot = snapshotProp ?? internal.snapshot;
const loading = signalLoadingProp ?? internal.loading;
const strategiesList = useMemo(() => {
if (strategies.length > 0) return strategies;
return strategy ? [strategy] : [];
}, [strategies, strategy]);
const card = (
<VirtualTargetCard
market={market}
strategy={strategy}
strategies={strategiesList}
strategyId={strategyId ?? null}
globalStrategyId={globalStrategyId}
snapshot={snapshot}
signalLoading={loading}
running={active}
liveStatus="live"
viewMode="summary"
displayMode={displayMode}
onDisplayModeChange={embedded ? undefined : setDisplayMode}
onEnterFocus={embedded ? undefined : onExpandCharts}
headVariant={embedded ? 'inline-quote' : 'default'}
theme={theme}
ticker={ticker}
chartLiveReceiveHighlight
readOnlyStrategyLabel={strategyLabel}
/>
);
if (embedded) {
return (
<div
className={`tnl-signal-summary--embedded-wrap se-page se-page--${theme}`}
onClick={e => e.stopPropagation()}
>
{card}
</div>
);
}
return card;
};
export default TradeNotificationSignalSummary;
@@ -35,6 +35,7 @@ export interface TradeSignalChartCardProps {
expanded: boolean;
onExpand: () => void;
onRestore: () => void;
onRealtimeActivity?: () => void;
}
const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
@@ -48,6 +49,7 @@ const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
expanded,
onExpand,
onRestore,
onRealtimeActivity,
}) => (
<section
className={[
@@ -67,6 +69,7 @@ const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
indicators={indicators}
enabled={enabled}
fillHeight={expanded}
onRealtimeActivity={onRealtimeActivity}
/>
{expanded ? (
<button
@@ -25,6 +25,8 @@ interface Props {
enabled?: boolean;
/** 전체보기 모드 — 부모 높이에 맞춤 */
fillHeight?: boolean;
/** STOMP/WS 실시간 봉 수신 시 (목록 행 형광 플래시 등) */
onRealtimeActivity?: () => void;
}
const noop = () => {};
@@ -36,6 +38,7 @@ const TradeSignalMiniChart: React.FC<Props> = ({
indicators = [],
enabled = true,
fillHeight = false,
onRealtimeActivity,
}) => {
const managerRef = useRef<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
@@ -70,11 +73,13 @@ const TradeSignalMiniChart: React.FC<Props> = ({
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
applyRealtimeBar(bar, false);
}, [applyRealtimeBar]);
onRealtimeActivity?.();
}, [applyRealtimeBar, onRealtimeActivity]);
const handleNewCandle = useCallback((bar: OHLCVBar) => {
applyRealtimeBar(bar, true);
}, [applyRealtimeBar]);
onRealtimeActivity?.();
}, [applyRealtimeBar, onRealtimeActivity]);
const useUpbit = isUpbitMarket(market);
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
import type { TrendSearchConditionDto } from '../../utils/trendSearchApi';
import { computeMatchRate, getTrafficLightState } from '../../utils/virtualSignalMetrics';
import { buildTrendSearchMetrics } from '../../utils/trendSearchMetrics';
@@ -31,6 +32,11 @@ const TrendSearchCardSignalPanel: React.FC<Props> = ({
() => getTrafficLightState(resolvedMatch, metrics),
[resolvedMatch, metrics],
);
const { visualRef, lightRef } = useSignalVisualRailHeight([
conditions.length,
resolvedMatch,
trafficState,
]);
if (conditions.length === 0) {
return <p className="vtd-muted vtd-card-empty"> </p>;
@@ -46,12 +52,18 @@ const TrendSearchCardSignalPanel: React.FC<Props> = ({
<div className="vtd-sig-panel-wrap vtd-sig-panel-wrap--detail">
<div className="vtd-sig-panel vtd-sig-panel--detail-top">
<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={resolvedMatch} />
</div>
<VirtualConditionList metrics={metrics} />
<VirtualSignalTrafficLight state={trafficState} matchRate={resolvedMatch} />
<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={resolvedMatch} />
</div>
</div>
</div>
</div>
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
@@ -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 && (