가상매매 화면 멀티 레이아웃 수정

This commit is contained in:
Macbook
2026-05-30 04:03:51 +09:00
parent 961609242c
commit 33c2d4b337
12 changed files with 476 additions and 217 deletions
+12 -4
View File
@@ -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 (
+13 -12
View File
@@ -56,9 +56,9 @@
/* 목록 한 줄 = 알림상세 · 신호일치율 · 캔들 · 보조지표(1칸) — 가로 100% 꽉 참 */
.tnl-row-gallery {
--tnl-summary-card-width: 288px;
--tnl-signal-slot-width: 308px;
--tnl-chart-card-width: 294px;
--tnl-summary-card-width: 260px;
--tnl-signal-slot-width: 280px;
--tnl-chart-card-width: 312px;
display: flex;
flex-direction: row;
align-items: stretch;
@@ -71,17 +71,17 @@
}
.tnl-row-gallery-detail {
flex: 1 1 0;
flex: 0.88 1 0;
min-width: 0;
}
.tnl-row-gallery-signal {
flex: 1.05 1 0;
flex: 0.92 1 0;
min-width: 0;
}
.tnl-row-gallery-candle {
flex: 1.05 1 0;
flex: 1.12 1 0;
min-width: 0;
height: 300px;
max-height: 300px;
@@ -107,7 +107,7 @@
}
.tnl-row-gallery-indicators {
flex: 1.05 1 0;
flex: 1.12 1 0;
min-width: 0;
}
@@ -123,9 +123,9 @@
}
.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail {
flex: 0 0 min(26%, var(--tnl-summary-card-width));
width: min(26%, var(--tnl-summary-card-width));
min-width: min(220px, var(--tnl-summary-card-width));
flex: 0 0 min(24%, var(--tnl-summary-card-width));
width: min(24%, var(--tnl-summary-card-width));
min-width: min(200px, var(--tnl-summary-card-width));
max-width: var(--tnl-summary-card-width);
}
@@ -591,6 +591,7 @@
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
padding-bottom: 6px;
}
.tnl-summary-info-row {
@@ -629,7 +630,7 @@
gap: 12px;
flex-shrink: 0;
width: 100%;
padding-top: 4px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
@@ -1213,7 +1214,7 @@ ul.tnl-list.tnl-list--grid {
}
.tnl-row-gallery {
--tnl-summary-card-width: min(100%, 288px);
--tnl-summary-card-width: min(100%, 260px);
}
.tnl-row-gallery-detail,
+134 -1
View File
@@ -971,6 +971,129 @@
padding: 0 4px;
}
/* 그리드 n×n 배치 선택 (가상매매 헤더) */
.vtd-grid-layout-picker {
position: relative;
flex-shrink: 0;
}
.vtd-grid-layout-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--se-border, var(--border));
border-radius: 8px;
background: var(--bg2, var(--se-panel-card-bg));
color: var(--text2);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.vtd-grid-layout-trigger:hover:not(:disabled) {
border-color: var(--accent, #3f7ef5);
color: var(--accent, #3f7ef5);
background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, var(--bg2));
}
.vtd-grid-layout-trigger--open {
border-color: var(--accent, #3f7ef5);
color: var(--accent, #3f7ef5);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 35%, transparent);
}
.vtd-grid-layout-trigger--disabled {
opacity: 0.4;
cursor: not-allowed;
}
.vtd-grid-layout-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 120;
width: min(320px, calc(100vw - 48px));
max-height: min(420px, 70vh);
overflow-y: auto;
padding: 10px 12px 12px;
border-radius: 10px;
border: 1px solid var(--se-border, var(--border));
background: var(--bg2, #1a1f2e);
box-shadow:
0 12px 32px color-mix(in srgb, var(--bg) 55%, transparent),
0 0 0 1px color-mix(in srgb, var(--text3) 12%, transparent);
}
.vtd-grid-layout-popover--align-left {
left: 0;
right: auto;
}
.vtd-grid-layout-popover-title {
margin: 0 0 10px;
font-size: 11px;
font-weight: 700;
color: var(--text3);
letter-spacing: 0.04em;
}
.vtd-grid-layout-group {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
}
.vtd-grid-layout-group:first-of-type {
border-top: none;
padding-top: 0;
}
.vtd-grid-layout-group-lbl {
flex: 0 0 14px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-align: center;
}
.vtd-grid-layout-group-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
min-width: 0;
}
.vtd-grid-preset-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text2);
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.vtd-grid-preset-btn:hover {
background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, transparent);
color: var(--text);
}
.vtd-grid-preset-btn--on {
border-color: var(--accent, #3f7ef5);
background: color-mix(in srgb, var(--accent, #3f7ef5) 14%, transparent);
color: var(--accent, #3f7ef5);
}
.vtd-grid-head-divider {
width: 1px;
height: 22px;
@@ -1063,6 +1186,11 @@
align-items: start;
}
/* 헤더에서 선택한 n×n 열 수 고정 배치 */
.vtd-grid--layout-preset {
grid-template-columns: repeat(var(--vtd-grid-cols, 2), minmax(0, 1fr)) !important;
}
/* 상세보기 — 행 높이 통일·카드 stretch */
.vtd-grid--detail {
grid-auto-rows: minmax(520px, auto);
@@ -1086,6 +1214,10 @@
.vtd-grid {
grid-template-columns: minmax(0, 1fr);
}
.vtd-grid--layout-preset {
grid-template-columns: minmax(0, 1fr) !important;
}
}
.vtd-card {
@@ -1378,7 +1510,8 @@
background: var(--se-center-bg);
}
.vtd-card-chart-canvas .chart-hover-toolbar {
.vtd-card-chart-canvas .chart-hover-toolbar,
.vtd-card-chart-canvas .candle-pane-controls {
display: none !important;
}
+2
View File
@@ -27,6 +27,8 @@ export interface UiPreferences {
targets?: VirtualTargetItem[];
session?: VirtualSessionConfig;
cardViewMode?: VirtualCardViewMode;
/** 종목 카드 그리드 열 배치 (tradeNotificationGridPresets id) */
gridPreset?: string;
};
tradeNotifications?: {
readIds?: string[];
@@ -1,5 +1,10 @@
import { resolveVirtualTargetNames } from './virtualTargetNames';
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
import {
DEFAULT_TRADE_NOTIFICATION_GRID_PRESET,
normalizeTradeNotificationGridPreset,
type TradeNotificationGridPresetId,
} from './tradeNotificationGridPresets';
/** 가상투자 대상 종목·세션 설정 (DB ui_preferences + per-market live settings) */
@@ -91,3 +96,13 @@ export function loadVirtualCardViewMode(): VirtualCardViewMode {
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
patchUiPreferences({ virtual: { cardViewMode: mode } });
}
export function loadVirtualGridPreset(): TradeNotificationGridPresetId {
return normalizeTradeNotificationGridPreset(
getUiPreferences().virtual?.gridPreset ?? DEFAULT_TRADE_NOTIFICATION_GRID_PRESET,
);
}
export function saveVirtualGridPreset(preset: TradeNotificationGridPresetId): void {
patchUiPreferences({ virtual: { gridPreset: preset } });
}