매매 시그널 알림 화면 수정
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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 && (
|
||||
|
||||
Reference in New Issue
Block a user