가상매매 화면 멀티 레이아웃 수정
This commit is contained in:
@@ -151,6 +151,10 @@ interface TradingChartProps {
|
|||||||
crosshairInfoVisible?: boolean;
|
crosshairInfoVisible?: boolean;
|
||||||
/** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */
|
/** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */
|
||||||
dataLoading?: boolean;
|
dataLoading?: boolean;
|
||||||
|
/** 마운트 시 캔들 pane 전체보기(하단 보조지표 pane 숨김) — 가상매매 카드 등 */
|
||||||
|
defaultCandleOnly?: boolean;
|
||||||
|
/** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */
|
||||||
|
showCandlePaneControls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TradingChart: React.FC<TradingChartProps> = ({
|
const TradingChart: React.FC<TradingChartProps> = ({
|
||||||
@@ -188,6 +192,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
showPaneLegend = true,
|
showPaneLegend = true,
|
||||||
crosshairInfoVisible = true,
|
crosshairInfoVisible = true,
|
||||||
dataLoading = false,
|
dataLoading = false,
|
||||||
|
defaultCandleOnly = false,
|
||||||
|
showCandlePaneControls = true,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||||
@@ -259,7 +265,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
/** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
|
/** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
|
||||||
const [chartPaintReady, setChartPaintReady] = useState(false);
|
const [chartPaintReady, setChartPaintReady] = useState(false);
|
||||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
/** 캔들 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);
|
const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -289,8 +296,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
}, [chartMgr]);
|
}, [chartMgr]);
|
||||||
|
|
||||||
const toggleCandleOnly = useCallback(() => {
|
const toggleCandleOnly = useCallback(() => {
|
||||||
|
if (candleOnlyLocked) return;
|
||||||
setCandleOnlyMode(v => !v);
|
setCandleOnlyMode(v => !v);
|
||||||
}, []);
|
}, [candleOnlyLocked]);
|
||||||
|
|
||||||
useEffect(() => { toggleCandleOnlyRef.current = toggleCandleOnly; }, [toggleCandleOnly]);
|
useEffect(() => { toggleCandleOnlyRef.current = toggleCandleOnly; }, [toggleCandleOnly]);
|
||||||
useEffect(() => { candleOnlyModeRef.current = candleOnlyMode; }, [candleOnlyMode]);
|
useEffect(() => { candleOnlyModeRef.current = candleOnlyMode; }, [candleOnlyMode]);
|
||||||
@@ -970,7 +978,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
|
|
||||||
if (m.isClickOnMainSeries(chartX, chartY)) {
|
if (m.isClickOnMainSeries(chartX, chartY)) {
|
||||||
onSeriesDoubleClickRef.current?.('__main__', { x: e.clientX, y: e.clientY });
|
onSeriesDoubleClickRef.current?.('__main__', { x: e.clientX, y: e.clientY });
|
||||||
} else {
|
} else if (!candleOnlyLocked) {
|
||||||
toggleCandleOnlyRef.current();
|
toggleCandleOnlyRef.current();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1577,7 +1585,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */}
|
{/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */}
|
||||||
{chartMgr && (
|
{chartMgr && showCandlePaneControls && !candleOnlyLocked && (
|
||||||
<CandlePaneControlsPortal
|
<CandlePaneControlsPortal
|
||||||
manager={chartMgr}
|
manager={chartMgr}
|
||||||
getContainer={() => containerRef.current}
|
getContainer={() => containerRef.current}
|
||||||
|
|||||||
@@ -40,12 +40,15 @@ import {
|
|||||||
saveVirtualTargets,
|
saveVirtualTargets,
|
||||||
loadVirtualCardViewMode,
|
loadVirtualCardViewMode,
|
||||||
saveVirtualCardViewMode,
|
saveVirtualCardViewMode,
|
||||||
|
loadVirtualGridPreset,
|
||||||
|
saveVirtualGridPreset,
|
||||||
type VirtualSessionConfig,
|
type VirtualSessionConfig,
|
||||||
type VirtualTargetItem,
|
type VirtualTargetItem,
|
||||||
type VirtualCardViewMode,
|
type VirtualCardViewMode,
|
||||||
} from '../utils/virtualTradingStorage';
|
} from '../utils/virtualTradingStorage';
|
||||||
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
||||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||||
|
import type { TradeNotificationGridPresetId } from '../utils/tradeNotificationGridPresets';
|
||||||
import {
|
import {
|
||||||
syncVirtualTargetsToBackend,
|
syncVirtualTargetsToBackend,
|
||||||
stopVirtualLiveOnBackend,
|
stopVirtualLiveOnBackend,
|
||||||
@@ -97,6 +100,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||||
|
const [gridPreset, setGridPreset] = useState<TradeNotificationGridPresetId>(() => loadVirtualGridPreset());
|
||||||
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -150,6 +154,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
}, [session.globalStrategyId]);
|
}, [session.globalStrategyId]);
|
||||||
|
|
||||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||||
|
useEffect(() => { saveVirtualGridPreset(gridPreset); }, [gridPreset]);
|
||||||
|
|
||||||
const strategyNames = useMemo(
|
const strategyNames = useMemo(
|
||||||
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
|
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
|
||||||
@@ -170,6 +175,10 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
setGlobalDisplayMode(mode);
|
setGlobalDisplayMode(mode);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => {
|
||||||
|
setGridPreset(preset);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const targetRefs = useMemo(() =>
|
const targetRefs = useMemo(() =>
|
||||||
targets.map(t => ({
|
targets.map(t => ({
|
||||||
market: t.market,
|
market: t.market,
|
||||||
@@ -405,6 +414,9 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
globalDisplayMode={globalDisplayMode}
|
globalDisplayMode={globalDisplayMode}
|
||||||
onGlobalDisplayModeChange={handleGlobalDisplayModeChange}
|
onGlobalDisplayModeChange={handleGlobalDisplayModeChange}
|
||||||
showSignalChartToggle={!focusMarket}
|
showSignalChartToggle={!focusMarket}
|
||||||
|
gridPreset={gridPreset}
|
||||||
|
onGridPresetChange={handleGridPresetChange}
|
||||||
|
showGridLayoutPicker={!focusMarket}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
leftStorageKey="vtd-left-width"
|
leftStorageKey="vtd-left-width"
|
||||||
@@ -448,6 +460,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
globalDisplayMode={globalDisplayMode}
|
globalDisplayMode={globalDisplayMode}
|
||||||
onFocusMarketChange={setFocusMarket}
|
onFocusMarketChange={setFocusMarket}
|
||||||
|
gridPreset={gridPreset}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
rightTabs={rightTabs}
|
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 GridLayoutPicker from '../common/GridLayoutPicker';
|
||||||
import {
|
import type { TradeNotificationGridPresetId } from '../../utils/tradeNotificationGridPresets';
|
||||||
gridPresetsByGroup,
|
|
||||||
getTradeNotificationGridPreset,
|
|
||||||
type TradeNotificationGridPreset,
|
|
||||||
type TradeNotificationGridPresetId,
|
|
||||||
} from '../../utils/tradeNotificationGridPresets';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: TradeNotificationGridPresetId;
|
value: TradeNotificationGridPresetId;
|
||||||
@@ -15,193 +10,8 @@ interface Props {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 20×20 와이어프레임 아이콘 */
|
const TradeNotificationGridLayoutPicker: React.FC<Props> = props => (
|
||||||
const LayoutIcon: React.FC<{ preset: TradeNotificationGridPreset }> = ({ preset }) => {
|
<GridLayoutPicker variant="tnl" popoverAlign="right" {...props} />
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TradeNotificationGridLayoutPicker;
|
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 { StrategyDto } from '../../utils/backendApi';
|
||||||
import type { VirtualSessionConfig, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
import type { VirtualSessionConfig, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||||
import type { VirtualCardDisplayMode } from './VirtualTargetCard';
|
import type { VirtualCardDisplayMode } from './VirtualTargetCard';
|
||||||
|
import VirtualGridLayoutPicker from './VirtualGridLayoutPicker';
|
||||||
|
import type { TradeNotificationGridPresetId } from '../../utils/tradeNotificationGridPresets';
|
||||||
|
|
||||||
interface SessionProps {
|
interface SessionProps {
|
||||||
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
session: Pick<VirtualSessionConfig, 'globalStrategyId' | 'executionType' | 'positionMode' | 'running'>;
|
||||||
@@ -23,6 +25,9 @@ interface ViewProps {
|
|||||||
globalDisplayMode?: VirtualCardDisplayMode;
|
globalDisplayMode?: VirtualCardDisplayMode;
|
||||||
onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
||||||
showSignalChartToggle?: boolean;
|
showSignalChartToggle?: boolean;
|
||||||
|
gridPreset?: TradeNotificationGridPresetId;
|
||||||
|
onGridPresetChange?: (preset: TradeNotificationGridPresetId) => void;
|
||||||
|
showGridLayoutPicker?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
|
export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
|
||||||
@@ -156,8 +161,21 @@ export const VirtualViewHeaderControls: React.FC<ViewProps> = ({
|
|||||||
globalDisplayMode = 'signal',
|
globalDisplayMode = 'signal',
|
||||||
onGlobalDisplayModeChange,
|
onGlobalDisplayModeChange,
|
||||||
showSignalChartToggle = true,
|
showSignalChartToggle = true,
|
||||||
|
gridPreset = '2x2',
|
||||||
|
onGridPresetChange,
|
||||||
|
showGridLayoutPicker = true,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="vtd-header-view">
|
<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 && (
|
{showSignalChartToggle && onGlobalDisplayModeChange && (
|
||||||
<>
|
<>
|
||||||
<div className="vtd-grid-head-group" role="group" aria-label="전체 종목 신호·차트">
|
<div className="vtd-grid-head-group" role="group" aria-label="전체 종목 신호·차트">
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
|||||||
mgr.updateBar(pending);
|
mgr.updateBar(pending);
|
||||||
const wrap = canvasWrapRef.current;
|
const wrap = canvasWrapRef.current;
|
||||||
if (wrap && wrap.clientHeight > 0) {
|
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 wrap = canvasWrapRef.current;
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
if (!wrap || wrap.clientHeight <= 0 || !mgr?.hasMainSeries()) return;
|
if (!wrap || wrap.clientHeight <= 0 || !mgr?.hasMainSeries()) return;
|
||||||
mgr.resetPaneHeights(wrap.clientHeight);
|
mgr.applyCandleOnlyLayout(true, wrap.clientHeight);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestChartReload = useCallback(() => {
|
const requestChartReload = useCallback(() => {
|
||||||
@@ -169,7 +169,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
|||||||
const h = canvasWrapRef.current.clientHeight;
|
const h = canvasWrapRef.current.clientHeight;
|
||||||
if (h <= 0) return;
|
if (h <= 0) return;
|
||||||
if (managerRef.current?.hasMainSeries()) {
|
if (managerRef.current?.hasMainSeries()) {
|
||||||
managerRef.current.resetPaneHeights(h);
|
managerRef.current.applyCandleOnlyLayout(true, h);
|
||||||
} else if (!chartReloadTriggeredRef.current && delay >= 800 && barsReady) {
|
} else if (!chartReloadTriggeredRef.current && delay >= 800 && barsReady) {
|
||||||
requestChartReload();
|
requestChartReload();
|
||||||
}
|
}
|
||||||
@@ -258,6 +258,9 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
|||||||
magnifierEnabled={false}
|
magnifierEnabled={false}
|
||||||
volumeVisible={false}
|
volumeVisible={false}
|
||||||
showHoverToolbar={false}
|
showHoverToolbar={false}
|
||||||
|
showPaneLegend={false}
|
||||||
|
showCandlePaneControls={false}
|
||||||
|
defaultCandleOnly
|
||||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||||
paneSeparatorOptions={chartPaneSeparator}
|
paneSeparatorOptions={chartPaneSeparator}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
|||||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||||
import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy';
|
import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy';
|
||||||
|
import {
|
||||||
|
getTradeNotificationGridPreset,
|
||||||
|
type TradeNotificationGridPresetId,
|
||||||
|
} from '../../utils/tradeNotificationGridPresets';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
targets: VirtualTargetItem[];
|
targets: VirtualTargetItem[];
|
||||||
@@ -34,6 +38,7 @@ interface Props {
|
|||||||
viewMode: VirtualCardViewMode;
|
viewMode: VirtualCardViewMode;
|
||||||
globalDisplayMode: VirtualCardDisplayMode;
|
globalDisplayMode: VirtualCardDisplayMode;
|
||||||
onFocusMarketChange?: (market: string | null) => void;
|
onFocusMarketChange?: (market: string | null) => void;
|
||||||
|
gridPreset: TradeNotificationGridPresetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualTargetGrid: React.FC<Props> = ({
|
const VirtualTargetGrid: React.FC<Props> = ({
|
||||||
@@ -50,7 +55,9 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
viewMode,
|
viewMode,
|
||||||
globalDisplayMode,
|
globalDisplayMode,
|
||||||
onFocusMarketChange,
|
onFocusMarketChange,
|
||||||
|
gridPreset,
|
||||||
}) => {
|
}) => {
|
||||||
|
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
|
||||||
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
||||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -132,7 +139,15 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
ticker={tickers?.get(focusTarget.market)}
|
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 => {
|
{targets.map(t => {
|
||||||
const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId));
|
const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId));
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -56,9 +56,9 @@
|
|||||||
|
|
||||||
/* 목록 한 줄 = 알림상세 · 신호일치율 · 캔들 · 보조지표(1칸) — 가로 100% 꽉 참 */
|
/* 목록 한 줄 = 알림상세 · 신호일치율 · 캔들 · 보조지표(1칸) — 가로 100% 꽉 참 */
|
||||||
.tnl-row-gallery {
|
.tnl-row-gallery {
|
||||||
--tnl-summary-card-width: 288px;
|
--tnl-summary-card-width: 260px;
|
||||||
--tnl-signal-slot-width: 308px;
|
--tnl-signal-slot-width: 280px;
|
||||||
--tnl-chart-card-width: 294px;
|
--tnl-chart-card-width: 312px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -71,17 +71,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery-detail {
|
.tnl-row-gallery-detail {
|
||||||
flex: 1 1 0;
|
flex: 0.88 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery-signal {
|
.tnl-row-gallery-signal {
|
||||||
flex: 1.05 1 0;
|
flex: 0.92 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery-candle {
|
.tnl-row-gallery-candle {
|
||||||
flex: 1.05 1 0;
|
flex: 1.12 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery-indicators {
|
.tnl-row-gallery-indicators {
|
||||||
flex: 1.05 1 0;
|
flex: 1.12 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +123,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail {
|
.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail {
|
||||||
flex: 0 0 min(26%, var(--tnl-summary-card-width));
|
flex: 0 0 min(24%, var(--tnl-summary-card-width));
|
||||||
width: min(26%, var(--tnl-summary-card-width));
|
width: min(24%, var(--tnl-summary-card-width));
|
||||||
min-width: min(220px, var(--tnl-summary-card-width));
|
min-width: min(200px, var(--tnl-summary-card-width));
|
||||||
max-width: var(--tnl-summary-card-width);
|
max-width: var(--tnl-summary-card-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,6 +591,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-summary-info-row {
|
.tnl-summary-info-row {
|
||||||
@@ -629,7 +630,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-top: 4px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,7 +1214,7 @@ ul.tnl-list.tnl-list--grid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery {
|
.tnl-row-gallery {
|
||||||
--tnl-summary-card-width: min(100%, 288px);
|
--tnl-summary-card-width: min(100%, 260px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery-detail,
|
.tnl-row-gallery-detail,
|
||||||
|
|||||||
@@ -971,6 +971,129 @@
|
|||||||
padding: 0 4px;
|
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 {
|
.vtd-grid-head-divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
@@ -1063,6 +1186,11 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 헤더에서 선택한 n×n 열 수 고정 배치 */
|
||||||
|
.vtd-grid--layout-preset {
|
||||||
|
grid-template-columns: repeat(var(--vtd-grid-cols, 2), minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 상세보기 — 행 높이 통일·카드 stretch */
|
/* 상세보기 — 행 높이 통일·카드 stretch */
|
||||||
.vtd-grid--detail {
|
.vtd-grid--detail {
|
||||||
grid-auto-rows: minmax(520px, auto);
|
grid-auto-rows: minmax(520px, auto);
|
||||||
@@ -1086,6 +1214,10 @@
|
|||||||
.vtd-grid {
|
.vtd-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-grid--layout-preset {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-card {
|
.vtd-card {
|
||||||
@@ -1378,7 +1510,8 @@
|
|||||||
background: var(--se-center-bg);
|
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;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface UiPreferences {
|
|||||||
targets?: VirtualTargetItem[];
|
targets?: VirtualTargetItem[];
|
||||||
session?: VirtualSessionConfig;
|
session?: VirtualSessionConfig;
|
||||||
cardViewMode?: VirtualCardViewMode;
|
cardViewMode?: VirtualCardViewMode;
|
||||||
|
/** 종목 카드 그리드 열 배치 (tradeNotificationGridPresets id) */
|
||||||
|
gridPreset?: string;
|
||||||
};
|
};
|
||||||
tradeNotifications?: {
|
tradeNotifications?: {
|
||||||
readIds?: string[];
|
readIds?: string[];
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
||||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||||
|
import {
|
||||||
|
DEFAULT_TRADE_NOTIFICATION_GRID_PRESET,
|
||||||
|
normalizeTradeNotificationGridPreset,
|
||||||
|
type TradeNotificationGridPresetId,
|
||||||
|
} from './tradeNotificationGridPresets';
|
||||||
|
|
||||||
/** 가상투자 대상 종목·세션 설정 (DB ui_preferences + per-market live settings) */
|
/** 가상투자 대상 종목·세션 설정 (DB ui_preferences + per-market live settings) */
|
||||||
|
|
||||||
@@ -91,3 +96,13 @@ export function loadVirtualCardViewMode(): VirtualCardViewMode {
|
|||||||
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||||
patchUiPreferences({ virtual: { cardViewMode: mode } });
|
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