가상매매 화면 멀티 레이아웃 수정
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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user