diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 6595f1a..c6acafd 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -151,6 +151,10 @@ interface TradingChartProps { crosshairInfoVisible?: boolean; /** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */ dataLoading?: boolean; + /** 마운트 시 캔들 pane 전체보기(하단 보조지표 pane 숨김) — 가상매매 카드 등 */ + defaultCandleOnly?: boolean; + /** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */ + showCandlePaneControls?: boolean; } const TradingChart: React.FC = ({ @@ -188,6 +192,8 @@ const TradingChart: React.FC = ({ showPaneLegend = true, crosshairInfoVisible = true, dataLoading = false, + defaultCandleOnly = false, + showCandlePaneControls = true, }) => { const containerRef = useRef(null); const wrapperRef = useRef(null); // 스크롤 래퍼 @@ -259,7 +265,8 @@ const TradingChart: React.FC = ({ /** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */ const [chartPaintReady, setChartPaintReady] = useState(false); /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */ - const [candleOnlyMode, setCandleOnlyMode] = useState(false); + const candleOnlyLocked = defaultCandleOnly; + const [candleOnlyMode, setCandleOnlyMode] = useState(defaultCandleOnly); const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null); useEffect(() => { @@ -289,8 +296,9 @@ const TradingChart: React.FC = ({ }, [chartMgr]); const toggleCandleOnly = useCallback(() => { + if (candleOnlyLocked) return; setCandleOnlyMode(v => !v); - }, []); + }, [candleOnlyLocked]); useEffect(() => { toggleCandleOnlyRef.current = toggleCandleOnly; }, [toggleCandleOnly]); useEffect(() => { candleOnlyModeRef.current = candleOnlyMode; }, [candleOnlyMode]); @@ -970,7 +978,7 @@ const TradingChart: React.FC = ({ if (m.isClickOnMainSeries(chartX, chartY)) { onSeriesDoubleClickRef.current?.('__main__', { x: e.clientX, y: e.clientY }); - } else { + } else if (!candleOnlyLocked) { toggleCandleOnlyRef.current(); } }; @@ -1577,7 +1585,7 @@ const TradingChart: React.FC = ({ /> )} {/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */} - {chartMgr && ( + {chartMgr && showCandlePaneControls && !candleOnlyLocked && ( containerRef.current} diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 378076b..5af7e8f 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -40,12 +40,15 @@ import { saveVirtualTargets, loadVirtualCardViewMode, saveVirtualCardViewMode, + loadVirtualGridPreset, + saveVirtualGridPreset, type VirtualSessionConfig, type VirtualTargetItem, type VirtualCardViewMode, } from '../utils/virtualTradingStorage'; import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings'; import { coerceFiniteNumber } from '../utils/safeFormat'; +import type { TradeNotificationGridPresetId } from '../utils/tradeNotificationGridPresets'; import { syncVirtualTargetsToBackend, stopVirtualLiveOnBackend, @@ -97,6 +100,7 @@ const VirtualTradingPage: React.FC = ({ const [fillBuy, setFillBuy] = useState(null); const [fillSell, setFillSell] = useState(null); const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode()); + const [gridPreset, setGridPreset] = useState(() => loadVirtualGridPreset()); const [globalDisplayMode, setGlobalDisplayMode] = useState('signal'); const [focusMarket, setFocusMarket] = useState(null); const orderAnchorRef = useRef(null); @@ -150,6 +154,7 @@ const VirtualTradingPage: React.FC = ({ }, [session.globalStrategyId]); useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]); + useEffect(() => { saveVirtualGridPreset(gridPreset); }, [gridPreset]); const strategyNames = useMemo( () => Object.fromEntries(strategies.map(s => [s.id, s.name])), @@ -170,6 +175,10 @@ const VirtualTradingPage: React.FC = ({ setGlobalDisplayMode(mode); }, []); + const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => { + setGridPreset(preset); + }, []); + const targetRefs = useMemo(() => targets.map(t => ({ market: t.market, @@ -405,6 +414,9 @@ const VirtualTradingPage: React.FC = ({ globalDisplayMode={globalDisplayMode} onGlobalDisplayModeChange={handleGlobalDisplayModeChange} showSignalChartToggle={!focusMarket} + gridPreset={gridPreset} + onGridPresetChange={handleGridPresetChange} + showGridLayoutPicker={!focusMarket} /> )} leftStorageKey="vtd-left-width" @@ -448,6 +460,7 @@ const VirtualTradingPage: React.FC = ({ viewMode={viewMode} globalDisplayMode={globalDisplayMode} onFocusMarketChange={setFocusMarket} + gridPreset={gridPreset} /> )} rightTabs={rightTabs} diff --git a/frontend/src/components/common/GridLayoutPicker.tsx b/frontend/src/components/common/GridLayoutPicker.tsx new file mode 100644 index 0000000..68c5846 --- /dev/null +++ b/frontend/src/components/common/GridLayoutPicker.tsx @@ -0,0 +1,224 @@ +/** + * 그리드형 n×n 레이아웃 선택 (TradingView 스타일 팝업) — 알림 목록·가상매매 공용 + */ +import React, { useEffect, useRef, useState } from 'react'; +import { + gridPresetsByGroup, + getTradeNotificationGridPreset, + type TradeNotificationGridPreset, + type TradeNotificationGridPresetId, +} from '../../utils/tradeNotificationGridPresets'; + +export type GridLayoutPickerVariant = 'tnl' | 'vtd'; + +interface Props { + value: TradeNotificationGridPresetId; + onChange: (id: TradeNotificationGridPresetId) => void; + disabled?: boolean; + /** CSS 클래스 접두사 — tnl(알림목록) | vtd(가상매매) */ + variant?: GridLayoutPickerVariant; + /** 팝업 정렬 (헤더 좌측 배치 시 left) */ + popoverAlign?: 'left' | 'right'; +} + +function cls(variant: GridLayoutPickerVariant, suffix: string): string { + return `${variant}-grid-layout-${suffix}`; +} + +/** 20×20 와이어프레임 아이콘 */ +const LayoutIcon: React.FC<{ preset: TradeNotificationGridPreset }> = ({ preset }) => { + const s = 20; + const g = 1; + const cells: Array<{ x: number; y: number; w: number; h: number }> = []; + + switch (preset.id) { + case '1x1': + cells.push({ x: 0, y: 0, w: s, h: s }); + break; + case '2x1': + cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: s }); + break; + case '1x2': + cells.push({ x: 0, y: 0, w: s, h: 9 }, { x: 0, y: 10, w: s, h: 9 }); + break; + case '3x1': + cells.push( + { x: 0, y: 0, w: 6, h: s }, + { x: 7, y: 0, w: 6, h: s }, + { x: 14, y: 0, w: 6, h: s }, + ); + break; + case '1x3': + cells.push( + { x: 0, y: 0, w: s, h: 6 }, + { x: 0, y: 7, w: s, h: 6 }, + { x: 0, y: 14, w: s, h: 6 }, + ); + break; + case '2x1-31': + cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: 9 }, { x: 10, y: 10, w: 9, h: 9 }); + break; + case '2x2': + cells.push( + { x: 0, y: 0, w: 9, h: 9 }, + { x: 10, y: 0, w: 9, h: 9 }, + { x: 0, y: 10, w: 9, h: 9 }, + { x: 10, y: 10, w: 9, h: 9 }, + ); + break; + case '4x1': + for (let i = 0; i < 4; i++) cells.push({ x: i * 5, y: 0, w: 4, h: s }); + break; + case '1x4': + for (let i = 0; i < 4; i++) cells.push({ x: 0, y: i * 5, w: s, h: 4 }); + break; + case '2x1-211': + cells.push( + { x: 0, y: 0, w: 9, h: 9 }, + { x: 10, y: 0, w: 9, h: 9 }, + { x: 0, y: 10, w: 9, h: 9 }, + { x: 10, y: 10, w: 9, h: 9 }, + ); + break; + case '5x1': + for (let i = 0; i < 5; i++) cells.push({ x: i * 4, y: 0, w: 3, h: s }); + break; + case '3x2': + for (let c = 0; c < 3; c++) { + for (let r = 0; r < 2; r++) { + cells.push({ x: c * 7, y: r * 10, w: 6, h: 9 }); + } + } + break; + case '2x3': + for (let c = 0; c < 2; c++) { + for (let r = 0; r < 3; r++) { + cells.push({ x: c * 10, y: r * 7, w: 9, h: 6 }); + } + } + break; + case '4x2': + for (let c = 0; c < 4; c++) { + for (let r = 0; r < 2; r++) { + cells.push({ x: c * 5, y: r * 10, w: 4, h: 9 }); + } + } + break; + case '2x4': + for (let c = 0; c < 2; c++) { + for (let r = 0; r < 4; r++) { + cells.push({ x: c * 10, y: r * 5, w: 9, h: 4 }); + } + } + break; + default: + cells.push({ x: 0, y: 0, w: s, h: s }); + } + + return ( + + {cells.map((c, i) => ( + + ))} + + ); +}; + +const GridLayoutPicker: React.FC = ({ + value, + onChange, + disabled = false, + variant = 'tnl', + popoverAlign = 'right', +}) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const active = getTradeNotificationGridPreset(value); + const groups = [...gridPresetsByGroup().entries()].sort((a, b) => a[0] - b[0]); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDoc); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const popoverClass = [ + cls(variant, 'popover'), + popoverAlign === 'left' ? cls(variant, 'popover--align-left') : '', + ].filter(Boolean).join(' '); + + return ( +
+ + + {open && ( +
+

그리드 배치

+ {groups.map(([group, presets]) => ( +
+ {group} +
+ {presets.map(preset => ( + + ))} +
+
+ ))} +
+ )} +
+ ); +}; + +export default GridLayoutPicker; diff --git a/frontend/src/components/tradeNotification/TradeNotificationGridLayoutPicker.tsx b/frontend/src/components/tradeNotification/TradeNotificationGridLayoutPicker.tsx index e899dd0..0f7e41d 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationGridLayoutPicker.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationGridLayoutPicker.tsx @@ -1,13 +1,8 @@ /** - * 그리드형 n×n 레이아웃 선택 (TradingView 스타일 팝업) + * 매매 시그널 알림 — 그리드 배치 선택 */ -import React, { useEffect, useRef, useState } from 'react'; -import { - gridPresetsByGroup, - getTradeNotificationGridPreset, - type TradeNotificationGridPreset, - type TradeNotificationGridPresetId, -} from '../../utils/tradeNotificationGridPresets'; +import GridLayoutPicker from '../common/GridLayoutPicker'; +import type { TradeNotificationGridPresetId } from '../../utils/tradeNotificationGridPresets'; interface Props { value: TradeNotificationGridPresetId; @@ -15,193 +10,8 @@ interface Props { disabled?: boolean; } -/** 20×20 와이어프레임 아이콘 */ -const LayoutIcon: React.FC<{ preset: TradeNotificationGridPreset }> = ({ preset }) => { - const s = 20; - const g = 1; - const cells: Array<{ x: number; y: number; w: number; h: number }> = []; - - switch (preset.id) { - case '1x1': - cells.push({ x: 0, y: 0, w: s, h: s }); - break; - case '2x1': - cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: s }); - break; - case '1x2': - cells.push({ x: 0, y: 0, w: s, h: 9 }, { x: 0, y: 10, w: s, h: 9 }); - break; - case '3x1': - cells.push( - { x: 0, y: 0, w: 6, h: s }, - { x: 7, y: 0, w: 6, h: s }, - { x: 14, y: 0, w: 6, h: s }, - ); - break; - case '1x3': - cells.push( - { x: 0, y: 0, w: s, h: 6 }, - { x: 0, y: 7, w: s, h: 6 }, - { x: 0, y: 14, w: s, h: 6 }, - ); - break; - case '2x1-31': - cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: 9 }, { x: 10, y: 10, w: 9, h: 9 }); - break; - case '2x2': - cells.push( - { x: 0, y: 0, w: 9, h: 9 }, - { x: 10, y: 0, w: 9, h: 9 }, - { x: 0, y: 10, w: 9, h: 9 }, - { x: 10, y: 10, w: 9, h: 9 }, - ); - break; - case '4x1': - for (let i = 0; i < 4; i++) cells.push({ x: i * 5, y: 0, w: 4, h: s }); - break; - case '1x4': - for (let i = 0; i < 4; i++) cells.push({ x: 0, y: i * 5, w: s, h: 4 }); - break; - case '2x1-211': - cells.push( - { x: 0, y: 0, w: 9, h: 9 }, - { x: 10, y: 0, w: 9, h: 9 }, - { x: 0, y: 10, w: 9, h: 9 }, - { x: 10, y: 10, w: 9, h: 9 }, - ); - break; - case '5x1': - for (let i = 0; i < 5; i++) cells.push({ x: i * 4, y: 0, w: 3, h: s }); - break; - case '3x2': - for (let c = 0; c < 3; c++) { - for (let r = 0; r < 2; r++) { - cells.push({ x: c * 7, y: r * 10, w: 6, h: 9 }); - } - } - break; - case '2x3': - for (let c = 0; c < 2; c++) { - for (let r = 0; r < 3; r++) { - cells.push({ x: c * 10, y: r * 7, w: 9, h: 6 }); - } - } - break; - case '4x2': - for (let c = 0; c < 4; c++) { - for (let r = 0; r < 2; r++) { - cells.push({ x: c * 5, y: r * 10, w: 4, h: 9 }); - } - } - break; - case '2x4': - for (let c = 0; c < 2; c++) { - for (let r = 0; r < 4; r++) { - cells.push({ x: c * 10, y: r * 5, w: 9, h: 4 }); - } - } - break; - default: - cells.push({ x: 0, y: 0, w: s, h: s }); - } - - return ( - - {cells.map((c, i) => ( - - ))} - - ); -}; - -const TradeNotificationGridLayoutPicker: React.FC = ({ - value, - onChange, - disabled = false, -}) => { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - const active = getTradeNotificationGridPreset(value); - const groups = [...gridPresetsByGroup().entries()].sort((a, b) => a[0] - b[0]); - - useEffect(() => { - if (!open) return; - const onDoc = (e: MouseEvent) => { - if (!rootRef.current?.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); - }; - document.addEventListener('mousedown', onDoc); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDoc); - document.removeEventListener('keydown', onKey); - }; - }, [open]); - - return ( -
- - - {open && ( -
-

그리드 배치

- {groups.map(([group, presets]) => ( -
- {group} -
- {presets.map(preset => ( - - ))} -
-
- ))} -
- )} -
- ); -}; +const TradeNotificationGridLayoutPicker: React.FC = props => ( + +); export default TradeNotificationGridLayoutPicker; diff --git a/frontend/src/components/virtual/VirtualGridLayoutPicker.tsx b/frontend/src/components/virtual/VirtualGridLayoutPicker.tsx new file mode 100644 index 0000000..0792626 --- /dev/null +++ b/frontend/src/components/virtual/VirtualGridLayoutPicker.tsx @@ -0,0 +1,17 @@ +/** + * 가상매매 — 그리드 n×n 배치 선택 + */ +import GridLayoutPicker from '../common/GridLayoutPicker'; +import type { TradeNotificationGridPresetId } from '../../utils/tradeNotificationGridPresets'; + +interface Props { + value: TradeNotificationGridPresetId; + onChange: (id: TradeNotificationGridPresetId) => void; + disabled?: boolean; +} + +const VirtualGridLayoutPicker: React.FC = props => ( + +); + +export default VirtualGridLayoutPicker; diff --git a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx index bea1a3c..3bdcfc1 100644 --- a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx +++ b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx @@ -2,6 +2,8 @@ import React from 'react'; import type { StrategyDto } from '../../utils/backendApi'; import type { VirtualSessionConfig, VirtualCardViewMode } from '../../utils/virtualTradingStorage'; import type { VirtualCardDisplayMode } from './VirtualTargetCard'; +import VirtualGridLayoutPicker from './VirtualGridLayoutPicker'; +import type { TradeNotificationGridPresetId } from '../../utils/tradeNotificationGridPresets'; interface SessionProps { session: Pick; @@ -23,6 +25,9 @@ interface ViewProps { globalDisplayMode?: VirtualCardDisplayMode; onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void; showSignalChartToggle?: boolean; + gridPreset?: TradeNotificationGridPresetId; + onGridPresetChange?: (preset: TradeNotificationGridPresetId) => void; + showGridLayoutPicker?: boolean; } export const VirtualSessionHeaderControls: React.FC = ({ @@ -156,8 +161,21 @@ export const VirtualViewHeaderControls: React.FC = ({ globalDisplayMode = 'signal', onGlobalDisplayModeChange, showSignalChartToggle = true, + gridPreset = '2x2', + onGridPresetChange, + showGridLayoutPicker = true, }) => (
+ {showGridLayoutPicker && onGridPresetChange && ( + <> + +