가상매매 화면 멀티 레이아웃 수정
This commit is contained in:
@@ -151,6 +151,10 @@ interface TradingChartProps {
|
||||
crosshairInfoVisible?: boolean;
|
||||
/** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */
|
||||
dataLoading?: boolean;
|
||||
/** 마운트 시 캔들 pane 전체보기(하단 보조지표 pane 숨김) — 가상매매 카드 등 */
|
||||
defaultCandleOnly?: boolean;
|
||||
/** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */
|
||||
showCandlePaneControls?: boolean;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
@@ -188,6 +192,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
showPaneLegend = true,
|
||||
crosshairInfoVisible = true,
|
||||
dataLoading = false,
|
||||
defaultCandleOnly = false,
|
||||
showCandlePaneControls = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
@@ -259,7 +265,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
/** 초기 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<TradingChartProps> = ({
|
||||
}, [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<TradingChartProps> = ({
|
||||
|
||||
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<TradingChartProps> = ({
|
||||
/>
|
||||
)}
|
||||
{/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */}
|
||||
{chartMgr && (
|
||||
{chartMgr && showCandlePaneControls && !candleOnlyLocked && (
|
||||
<CandlePaneControlsPortal
|
||||
manager={chartMgr}
|
||||
getContainer={() => containerRef.current}
|
||||
|
||||
@@ -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<Props> = ({
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||
const [gridPreset, setGridPreset] = useState<TradeNotificationGridPresetId>(() => loadVirtualGridPreset());
|
||||
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -150,6 +154,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
}, [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<Props> = ({
|
||||
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<Props> = ({
|
||||
globalDisplayMode={globalDisplayMode}
|
||||
onGlobalDisplayModeChange={handleGlobalDisplayModeChange}
|
||||
showSignalChartToggle={!focusMarket}
|
||||
gridPreset={gridPreset}
|
||||
onGridPresetChange={handleGridPresetChange}
|
||||
showGridLayoutPicker={!focusMarket}
|
||||
/>
|
||||
)}
|
||||
leftStorageKey="vtd-left-width"
|
||||
@@ -448,6 +460,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
viewMode={viewMode}
|
||||
globalDisplayMode={globalDisplayMode}
|
||||
onFocusMarketChange={setFocusMarket}
|
||||
gridPreset={gridPreset}
|
||||
/>
|
||||
)}
|
||||
rightTabs={rightTabs}
|
||||
|
||||
@@ -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 (
|
||||
<svg width="22" height="22" viewBox="0 0 20 20" aria-hidden className="grid-preset-icon-svg">
|
||||
{cells.map((c, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={c.x}
|
||||
y={c.y}
|
||||
width={c.w}
|
||||
height={c.h}
|
||||
rx={0.5}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={g}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const GridLayoutPicker: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
variant = 'tnl',
|
||||
popoverAlign = 'right',
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={cls(variant, 'picker')} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
cls(variant, 'trigger'),
|
||||
open ? cls(variant, 'trigger--open') : '',
|
||||
disabled ? cls(variant, 'trigger--disabled') : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
title={`그리드 배치: ${active.label}`}
|
||||
aria-label="그리드 배치 선택"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen(o => !o)}
|
||||
>
|
||||
<LayoutIcon preset={active} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={popoverClass} role="dialog" aria-label="그리드 배치">
|
||||
<p className={cls(variant, 'popover-title')}>그리드 배치</p>
|
||||
{groups.map(([group, presets]) => (
|
||||
<div key={group} className={cls(variant, 'group')}>
|
||||
<span className={cls(variant, 'group-lbl')}>{group}</span>
|
||||
<div className={cls(variant, 'group-row')}>
|
||||
{presets.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className={[
|
||||
`${variant}-grid-preset-btn`,
|
||||
value === preset.id ? `${variant}-grid-preset-btn--on` : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
title={`${preset.label} (${preset.cols}열)`}
|
||||
aria-label={`${preset.label} 배치`}
|
||||
aria-pressed={value === preset.id}
|
||||
onClick={() => {
|
||||
onChange(preset.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<LayoutIcon preset={preset} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridLayoutPicker;
|
||||
@@ -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 (
|
||||
<svg width="22" height="22" viewBox="0 0 20 20" aria-hidden className="tnl-grid-preset-icon-svg">
|
||||
{cells.map((c, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={c.x}
|
||||
y={c.y}
|
||||
width={c.w}
|
||||
height={c.h}
|
||||
rx={0.5}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={g}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const TradeNotificationGridLayoutPicker: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="tnl-grid-layout-picker" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'tnl-grid-layout-trigger',
|
||||
open ? 'tnl-grid-layout-trigger--open' : '',
|
||||
disabled ? 'tnl-grid-layout-trigger--disabled' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
title={`그리드 배치: ${active.label}`}
|
||||
aria-label="그리드 배치 선택"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen(o => !o)}
|
||||
>
|
||||
<LayoutIcon preset={active} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="tnl-grid-layout-popover" role="dialog" aria-label="그리드 배치">
|
||||
<p className="tnl-grid-layout-popover-title">그리드 배치</p>
|
||||
{groups.map(([group, presets]) => (
|
||||
<div key={group} className="tnl-grid-layout-group">
|
||||
<span className="tnl-grid-layout-group-lbl">{group}</span>
|
||||
<div className="tnl-grid-layout-group-row">
|
||||
{presets.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className={[
|
||||
'tnl-grid-preset-btn',
|
||||
value === preset.id ? 'tnl-grid-preset-btn--on' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
title={`${preset.label} (${preset.cols}열)`}
|
||||
aria-label={`${preset.label} 배치`}
|
||||
aria-pressed={value === preset.id}
|
||||
onClick={() => {
|
||||
onChange(preset.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<LayoutIcon preset={preset} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const TradeNotificationGridLayoutPicker: React.FC<Props> = props => (
|
||||
<GridLayoutPicker variant="tnl" popoverAlign="right" {...props} />
|
||||
);
|
||||
|
||||
export default TradeNotificationGridLayoutPicker;
|
||||
|
||||
@@ -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> = props => (
|
||||
<GridLayoutPicker variant="vtd" popoverAlign="left" {...props} />
|
||||
);
|
||||
|
||||
export default VirtualGridLayoutPicker;
|
||||
@@ -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<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||
@@ -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<SessionProps> = ({
|
||||
@@ -156,8 +161,21 @@ export const VirtualViewHeaderControls: React.FC<ViewProps> = ({
|
||||
globalDisplayMode = 'signal',
|
||||
onGlobalDisplayModeChange,
|
||||
showSignalChartToggle = true,
|
||||
gridPreset = '2x2',
|
||||
onGridPresetChange,
|
||||
showGridLayoutPicker = true,
|
||||
}) => (
|
||||
<div className="vtd-header-view">
|
||||
{showGridLayoutPicker && onGridPresetChange && (
|
||||
<>
|
||||
<VirtualGridLayoutPicker
|
||||
value={gridPreset}
|
||||
onChange={onGridPresetChange}
|
||||
disabled={!showSignalChartToggle}
|
||||
/>
|
||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
{showSignalChartToggle && onGlobalDisplayModeChange && (
|
||||
<>
|
||||
<div className="vtd-grid-head-group" role="group" aria-label="전체 종목 신호·차트">
|
||||
|
||||
@@ -95,7 +95,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
mgr.updateBar(pending);
|
||||
const wrap = canvasWrapRef.current;
|
||||
if (wrap && wrap.clientHeight > 0) {
|
||||
mgr.resetPaneHeights(wrap.clientHeight);
|
||||
mgr.applyCandleOnlyLayout(true, wrap.clientHeight);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -145,7 +145,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
const wrap = canvasWrapRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!wrap || wrap.clientHeight <= 0 || !mgr?.hasMainSeries()) return;
|
||||
mgr.resetPaneHeights(wrap.clientHeight);
|
||||
mgr.applyCandleOnlyLayout(true, wrap.clientHeight);
|
||||
}, []);
|
||||
|
||||
const requestChartReload = useCallback(() => {
|
||||
@@ -169,7 +169,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
const h = canvasWrapRef.current.clientHeight;
|
||||
if (h <= 0) return;
|
||||
if (managerRef.current?.hasMainSeries()) {
|
||||
managerRef.current.resetPaneHeights(h);
|
||||
managerRef.current.applyCandleOnlyLayout(true, h);
|
||||
} else if (!chartReloadTriggeredRef.current && delay >= 800 && barsReady) {
|
||||
requestChartReload();
|
||||
}
|
||||
@@ -258,6 +258,9 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
magnifierEnabled={false}
|
||||
volumeVisible={false}
|
||||
showHoverToolbar={false}
|
||||
showPaneLegend={false}
|
||||
showCandlePaneControls={false}
|
||||
defaultCandleOnly
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,10 @@ import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy';
|
||||
import {
|
||||
getTradeNotificationGridPreset,
|
||||
type TradeNotificationGridPresetId,
|
||||
} from '../../utils/tradeNotificationGridPresets';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -34,6 +38,7 @@ interface Props {
|
||||
viewMode: VirtualCardViewMode;
|
||||
globalDisplayMode: VirtualCardDisplayMode;
|
||||
onFocusMarketChange?: (market: string | null) => void;
|
||||
gridPreset: TradeNotificationGridPresetId;
|
||||
}
|
||||
|
||||
const VirtualTargetGrid: React.FC<Props> = ({
|
||||
@@ -50,7 +55,9 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
viewMode,
|
||||
globalDisplayMode,
|
||||
onFocusMarketChange,
|
||||
gridPreset,
|
||||
}) => {
|
||||
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
|
||||
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||
|
||||
@@ -132,7 +139,15 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
ticker={tickers?.get(focusTarget.market)}
|
||||
/>
|
||||
) : (
|
||||
<div className={`vtd-grid vtd-grid--${viewMode}`}>
|
||||
<div
|
||||
className={[
|
||||
'vtd-grid',
|
||||
'vtd-grid--layout-preset',
|
||||
`vtd-grid--${viewMode}`,
|
||||
`vtd-grid--cols-${gridCols}`,
|
||||
].join(' ')}
|
||||
style={{ ['--vtd-grid-cols' as string]: gridCols }}
|
||||
>
|
||||
{targets.map(t => {
|
||||
const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId));
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user