매매 시그널 알림 화면 수정
This commit is contained in:
@@ -5,7 +5,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Theme, TradeOrderFillRequest } from '../types';
|
||||
import type { TickerData } from '../hooks/useMarketTicker';
|
||||
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
||||
import { buildSignalDetailRows, formatSignalPrice, getSignalHeadline } from '../utils/tradeSignalDisplay';
|
||||
import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
import TradeOrderPanel from './TradeOrderPanel';
|
||||
@@ -14,6 +13,20 @@ import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
||||
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
import '../styles/tradeNotificationList.css';
|
||||
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
|
||||
import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker';
|
||||
import {
|
||||
loadTradeNotificationGridPreset,
|
||||
loadTradeNotificationListLayout,
|
||||
saveTradeNotificationGridPreset,
|
||||
saveTradeNotificationListLayout,
|
||||
type TradeNotificationListLayout,
|
||||
} from '../utils/tradeNotificationListLayout';
|
||||
import {
|
||||
getTradeNotificationGridPreset,
|
||||
type TradeNotificationGridPresetId,
|
||||
} from '../utils/tradeNotificationGridPresets';
|
||||
|
||||
type RightTab = 'trade' | 'orderbook';
|
||||
|
||||
@@ -69,6 +82,12 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
} = useTradeNotification();
|
||||
|
||||
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
||||
const [listLayout, setListLayout] = useState<TradeNotificationListLayout>(
|
||||
() => loadTradeNotificationListLayout(),
|
||||
);
|
||||
const [gridPreset, setGridPreset] = useState<TradeNotificationGridPresetId>(
|
||||
() => loadTradeNotificationGridPreset(),
|
||||
);
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [selectedNotifyId, setSelectedNotifyId] = useState<string | null>(null);
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
@@ -172,15 +191,57 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
? allNotifications.filter(n => !n.isRead)
|
||||
: allNotifications;
|
||||
|
||||
const handleListLayoutChange = useCallback((layout: TradeNotificationListLayout) => {
|
||||
setListLayout(layout);
|
||||
saveTradeNotificationListLayout(layout);
|
||||
}, []);
|
||||
|
||||
const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => {
|
||||
setGridPreset(preset);
|
||||
saveTradeNotificationGridPreset(preset);
|
||||
}, []);
|
||||
|
||||
const isListView = listLayout === 'list';
|
||||
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
|
||||
|
||||
return (
|
||||
<div className={`tnl-page tnl-page--with-right bps-page--vtd app ${theme}`}>
|
||||
<div className="tnl-body">
|
||||
<div className="tnl-main">
|
||||
<div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
|
||||
<div className="tnl-header">
|
||||
<div className="tnl-header-row">
|
||||
<div className="tnl-header-intro">
|
||||
<h1 className="tnl-title">매매 시그널 알림</h1>
|
||||
<p className="tnl-sub">
|
||||
알림을 선택하면 우측 매매 패널에 종목·가격이 자동 입력됩니다.
|
||||
{isListView
|
||||
? '각 알림 왼쪽은 요약, 오른쪽은 캔들·지표 차트입니다. 차트 영역은 가로 스크롤로 확인할 수 있습니다.'
|
||||
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 우측 아이콘으로 배치를 변경할 수 있습니다.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="tnl-header-actions">
|
||||
<div className="tnl-layout-toggle vtd-view-toggle" role="group" aria-label="목록 표시 방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${isListView ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => handleListLayoutChange('list')}
|
||||
>
|
||||
목록형
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${!isListView ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => handleListLayoutChange('grid')}
|
||||
>
|
||||
그리드형
|
||||
</button>
|
||||
</div>
|
||||
<TradeNotificationGridLayoutPicker
|
||||
value={gridPreset}
|
||||
onChange={handleGridPresetChange}
|
||||
disabled={isListView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tnl-toolbar">
|
||||
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}건</span>
|
||||
<div className="tnl-filter">
|
||||
@@ -231,76 +292,38 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tnl-list-wrap">
|
||||
<div className={['tnl-list-wrap', !isListView ? 'tnl-list-wrap--grid' : ''].filter(Boolean).join(' ')}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="tnl-empty">표시할 알림이 없습니다.</div>
|
||||
) : (
|
||||
<ul className="tnl-list">
|
||||
{filtered.map(item => {
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
const detailRows = buildSignalDetailRows(item);
|
||||
const isSelected = selectedNotifyId === item.id;
|
||||
return (
|
||||
<li
|
||||
<ul
|
||||
className={[
|
||||
'tnl-list',
|
||||
isListView ? 'tnl-list--gallery' : 'tnl-list--grid',
|
||||
!isListView ? `tnl-grid--cols-${gridCols}` : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={!isListView ? { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` } : undefined}
|
||||
>
|
||||
{filtered.map(item => (
|
||||
<TradeNotificationListRow
|
||||
key={item.id}
|
||||
className={`tnl-row ${!item.isRead ? 'tnl-row--unread' : ''}${isSelected ? ' tnl-row--selected' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-main"
|
||||
onClick={() => handleNotificationSelect(item)}
|
||||
>
|
||||
<span className={`tnl-signal ${isBuy ? 'tnl-signal--buy' : 'tnl-signal--sell'}`}>
|
||||
{item.signalType}
|
||||
</span>
|
||||
<span className="tnl-row-body">
|
||||
<span className="tnl-row-title">{getSignalHeadline(item)}</span>
|
||||
<span className="tnl-row-price">{formatSignalPrice(item.price)}</span>
|
||||
<span className="tnl-row-detail-grid">
|
||||
{detailRows.map(row => (
|
||||
<span key={row.label} className="tnl-row-detail-item">
|
||||
<span className="tnl-row-detail-lbl">{row.label}</span>
|
||||
<span className="tnl-row-detail-val">{row.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
{!item.isRead && <span className="tnl-dot" aria-hidden />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-icon-btn"
|
||||
title="삭제"
|
||||
aria-label="알림 삭제"
|
||||
onClick={e => void handleDeleteOne(item, e)}
|
||||
>
|
||||
<IcTrash />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-action"
|
||||
title="상세 보기"
|
||||
onClick={() => {
|
||||
item={item}
|
||||
theme={theme}
|
||||
layoutMode={listLayout}
|
||||
isSelected={selectedNotifyId === item.id}
|
||||
chartsEnabled={isListView}
|
||||
onSelect={() => handleNotificationSelect(item)}
|
||||
onDelete={e => void handleDeleteOne(item, e)}
|
||||
onDetail={() => {
|
||||
if (!item.isRead) markAsRead(item.id);
|
||||
openDetail(item, { centered: true });
|
||||
}}
|
||||
>
|
||||
상세
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-chart"
|
||||
title="차트로 이동"
|
||||
onClick={() => {
|
||||
onGoToChart={() => {
|
||||
markAsRead(item.id);
|
||||
onGoToChart(item.market);
|
||||
}}
|
||||
>
|
||||
차트
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -131,6 +131,8 @@ interface TradingChartProps {
|
||||
chartVisible?: boolean;
|
||||
/** pane(캔들·거래량·보조지표) 구분선 */
|
||||
paneSeparatorOptions?: ChartPaneSeparatorOptions;
|
||||
/** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
|
||||
paneLayoutClamp?: boolean;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
@@ -162,6 +164,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
seriesPriceLabelsEnabled = true,
|
||||
chartVisible = true,
|
||||
paneSeparatorOptions,
|
||||
paneLayoutClamp = false,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
@@ -325,13 +328,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
required = mgr.resetPaneHeights(wrapperH);
|
||||
}
|
||||
|
||||
if (required > wrapperH + 4) {
|
||||
if (!paneLayoutClamp && required > wrapperH + 4) {
|
||||
container.style.height = `${required}px`;
|
||||
} else {
|
||||
container.style.height = '';
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [paneLayoutClamp]);
|
||||
|
||||
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 그리드형 n×n 레이아웃 선택 (TradingView 스타일 팝업)
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
gridPresetsByGroup,
|
||||
getTradeNotificationGridPreset,
|
||||
type TradeNotificationGridPreset,
|
||||
type TradeNotificationGridPresetId,
|
||||
} from '../../utils/tradeNotificationGridPresets';
|
||||
|
||||
interface Props {
|
||||
value: TradeNotificationGridPresetId;
|
||||
onChange: (id: TradeNotificationGridPresetId) => void;
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeNotificationGridLayoutPicker;
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 매매 시그널 알림 목록 행 — 좌: 요약 카드(고정) · 우: 캔들+지표 카드(가로 스크롤)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
|
||||
import {
|
||||
buildSignalDetailRows,
|
||||
formatCandleTypeKo,
|
||||
formatSignalPrice,
|
||||
getMarketDisplayLine,
|
||||
getSignalTypeKo,
|
||||
} from '../../utils/tradeSignalDisplay';
|
||||
import {
|
||||
buildStrategyChartIndicators,
|
||||
candleTypeToTimeframe,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import TradeSignalChartCard from './TradeSignalChartCard';
|
||||
|
||||
const IcTrash = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type TradeNotificationRowLayout = 'list' | 'grid';
|
||||
|
||||
interface Props {
|
||||
item: TradeNotificationItem;
|
||||
theme: Theme;
|
||||
isSelected: boolean;
|
||||
layoutMode?: TradeNotificationRowLayout;
|
||||
chartsEnabled?: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: (e: React.MouseEvent) => void;
|
||||
onDetail: () => void;
|
||||
onGoToChart: () => void;
|
||||
}
|
||||
|
||||
const MAX_INDICATOR_CARDS = 8;
|
||||
|
||||
const TradeNotificationListRow: React.FC<Props> = ({
|
||||
item,
|
||||
theme,
|
||||
isSelected,
|
||||
layoutMode = 'list',
|
||||
chartsEnabled = true,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDetail,
|
||||
onGoToChart,
|
||||
}) => {
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
const allDetailRows = buildSignalDetailRows(item);
|
||||
const metaRows = allDetailRows.filter(
|
||||
row => row.label !== '종목' && row.label !== '매매 구분' && row.label !== '기준 가격' && row.label !== '전략',
|
||||
);
|
||||
const strategyRow = allDetailRows.find(row => row.label === '전략');
|
||||
const { primary, secondary } = getMarketDisplayLine(item.market);
|
||||
const sym = item.market.replace(/^KRW-/, '');
|
||||
const isListLayout = layoutMode === 'list';
|
||||
const chartTf = candleTypeToTimeframe(item.candleType ?? '1m');
|
||||
const candleKo = formatCandleTypeKo(item.candleType);
|
||||
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||
const strategyLabel = item.strategyName?.trim()
|
||||
|| strategy?.name
|
||||
|| strategyRow?.value
|
||||
|| '실시간 전략';
|
||||
const rowRef = useRef<HTMLLIElement>(null);
|
||||
const [inView, setInView] = useState(false);
|
||||
/** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */
|
||||
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isListLayout) return;
|
||||
const el = rowRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0]?.isIntersecting) setInView(true);
|
||||
},
|
||||
{ rootMargin: '120px 0px', threshold: 0.05 },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [isListLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isListLayout || !item.strategyId || !chartsEnabled) {
|
||||
setStrategy(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadStrategy(item.strategyId).then(s => {
|
||||
if (!cancelled && s) setStrategy(s);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [item.strategyId, chartsEnabled, isListLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedChartKey(null);
|
||||
}, [item.id, layoutMode]);
|
||||
|
||||
const indicatorCards = useMemo(() => {
|
||||
if (!isListLayout || !strategy) return [];
|
||||
const all = buildStrategyChartIndicators(strategy, getParams, getVisualConfig);
|
||||
return all.slice(0, MAX_INDICATOR_CARDS).map(ind => ({
|
||||
id: ind.id,
|
||||
type: ind.type,
|
||||
label: formatIndicatorDisplayLabel(ind.type),
|
||||
config: [ind],
|
||||
}));
|
||||
}, [strategy, getParams, getVisualConfig, isListLayout]);
|
||||
|
||||
const chartActive = isListLayout && chartsEnabled && inView;
|
||||
const chartExpanded = isListLayout && expandedChartKey != null;
|
||||
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
|
||||
|
||||
const summaryCard = (
|
||||
<article
|
||||
className={[
|
||||
'tnl-summary-card',
|
||||
isBuy ? 'tnl-summary-card--buy' : 'tnl-summary-card--sell',
|
||||
!isListLayout ? 'tnl-summary-card--grid' : '',
|
||||
isSelected ? 'tnl-summary-card--selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-label="알림 요약"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-summary-panel"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="tnl-summary-head">
|
||||
<div className="tnl-summary-title">
|
||||
<span className="tnl-summary-ko">{primary}</span>
|
||||
<span className="tnl-summary-sym">{sym || secondary}</span>
|
||||
</div>
|
||||
<div className="tnl-summary-head-actions">
|
||||
<span className={`tnl-signal ${isBuy ? 'tnl-signal--buy' : 'tnl-signal--sell'}`}>
|
||||
{item.signalType}
|
||||
</span>
|
||||
{!item.isRead && <span className="tnl-dot tnl-summary-dot" aria-label="미확인" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tnl-summary-quote" aria-label="시그널 가격">
|
||||
<div className="tnl-summary-quote-left">
|
||||
<span
|
||||
className={`tnl-summary-arrow${isBuy ? ' tnl-summary-arrow--buy' : ' tnl-summary-arrow--sell'}`}
|
||||
aria-hidden
|
||||
>
|
||||
{isBuy ? '▲' : '▼'}
|
||||
</span>
|
||||
<span className="tnl-summary-pair">{sym || secondary}/KRW</span>
|
||||
<span className={`tnl-summary-type${isBuy ? ' tnl-summary-type--buy' : ' tnl-summary-type--sell'}`}>
|
||||
{getSignalTypeKo(item.signalType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tnl-summary-quote-right">
|
||||
<span
|
||||
className={`tnl-summary-price${isBuy ? ' tnl-summary-price--buy' : ' tnl-summary-price--sell'}`}
|
||||
>
|
||||
{formatSignalPrice(item.price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tnl-summary-meta">
|
||||
{metaRows.map(row => (
|
||||
<div key={row.label} className="tnl-summary-info-row">
|
||||
<span className="tnl-summary-info-lbl">{row.label}</span>
|
||||
<span className={`tnl-summary-info-val${row.highlight ? ' tnl-summary-info-val--hi' : ''}`}>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="tnl-summary-strategy-field">
|
||||
<span className="tnl-summary-strategy-lbl">투자전략</span>
|
||||
<span className="tnl-summary-strategy-box" title={strategyLabel}>
|
||||
{strategyLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="tnl-summary-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-icon-btn"
|
||||
title="삭제"
|
||||
aria-label="알림 삭제"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<IcTrash />
|
||||
</button>
|
||||
<button type="button" className="tnl-row-action" onClick={onDetail}>
|
||||
상세
|
||||
</button>
|
||||
<button type="button" className="tnl-row-chart" onClick={onGoToChart}>
|
||||
차트
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
if (!isListLayout) {
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
className={[
|
||||
'tnl-row',
|
||||
'tnl-row--grid',
|
||||
!item.isRead ? 'tnl-row--unread' : '',
|
||||
isSelected ? 'tnl-row--selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{summaryCard}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
className={[
|
||||
'tnl-row',
|
||||
'tnl-row--gallery',
|
||||
!item.isRead ? 'tnl-row--unread' : '',
|
||||
isSelected ? 'tnl-row--selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}>
|
||||
{summaryCard}
|
||||
|
||||
{chartExpanded ? (
|
||||
<div className="tnl-charts-expanded" aria-label="차트 전체보기">
|
||||
{expandedChartKey === 'candle' && (
|
||||
<TradeSignalChartCard
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={[]}
|
||||
enabled={chartActive}
|
||||
expanded
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
)}
|
||||
{expandedIndicator && (
|
||||
<TradeSignalChartCard
|
||||
label={expandedIndicator.label}
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={expandedIndicator.config}
|
||||
enabled={chartActive}
|
||||
expanded
|
||||
onExpand={() => setExpandedChartKey(expandedIndicator.id)}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tnl-charts-scroll" aria-label="캔들·지표 차트">
|
||||
<div className="tnl-charts-track">
|
||||
<TradeSignalChartCard
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={[]}
|
||||
enabled={chartActive}
|
||||
expanded={false}
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
|
||||
{indicatorCards.map(card => (
|
||||
<TradeSignalChartCard
|
||||
key={card.id}
|
||||
label={card.label}
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={card.config}
|
||||
enabled={chartActive}
|
||||
expanded={false}
|
||||
onExpand={() => setExpandedChartKey(card.id)}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{item.strategyId != null && indicatorCards.length === 0 && chartActive && (
|
||||
<section className="tnl-chart-card tnl-chart-card--placeholder">
|
||||
<p className="tnl-muted">전략 지표 로딩 중…</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeNotificationListRow;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 매매 시그널 알림 — 캔들·지표 미니 차트 카드 (펼쳐보기 / 원래보기)
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { IndicatorConfig } from '../../types';
|
||||
import type { Theme, Timeframe } from '../../types';
|
||||
import TradeSignalMiniChart from './TradeSignalMiniChart';
|
||||
|
||||
const IcExpand = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="5,1 1,1 1,5" />
|
||||
<polyline points="9,13 13,13 13,9" />
|
||||
<line x1="1" y1="1" x2="6" y2="6" />
|
||||
<line x1="13" y1="13" x2="8" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcRestore = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="5,5 1,5 1,1" />
|
||||
<polyline points="9,9 13,9 13,13" />
|
||||
<line x1="1" y1="5" x2="5" y2="1" />
|
||||
<line x1="13" y1="9" x2="9" y2="13" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export interface TradeSignalChartCardProps {
|
||||
label: string;
|
||||
meta: string;
|
||||
market: string;
|
||||
timeframe: Timeframe;
|
||||
theme: Theme;
|
||||
indicators: IndicatorConfig[];
|
||||
enabled: boolean;
|
||||
expanded: boolean;
|
||||
onExpand: () => void;
|
||||
onRestore: () => void;
|
||||
}
|
||||
|
||||
const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
|
||||
label,
|
||||
meta,
|
||||
market,
|
||||
timeframe,
|
||||
theme,
|
||||
indicators,
|
||||
enabled,
|
||||
expanded,
|
||||
onExpand,
|
||||
onRestore,
|
||||
}) => (
|
||||
<section
|
||||
className={[
|
||||
'tnl-chart-card',
|
||||
expanded ? 'tnl-chart-card--expanded' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<header className="tnl-chart-card-head">
|
||||
<span className="tnl-chart-card-label">{label}</span>
|
||||
<span className="tnl-chart-card-meta">{meta}</span>
|
||||
</header>
|
||||
<div className="tnl-chart-card-body">
|
||||
<TradeSignalMiniChart
|
||||
market={market}
|
||||
timeframe={timeframe}
|
||||
theme={theme}
|
||||
indicators={indicators}
|
||||
enabled={enabled}
|
||||
fillHeight={expanded}
|
||||
/>
|
||||
{expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-chart-view-btn tnl-chart-view-btn--restore"
|
||||
title="원래보기"
|
||||
aria-label="원래보기"
|
||||
onClick={e => { e.stopPropagation(); onRestore(); }}
|
||||
>
|
||||
<IcRestore />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-chart-view-btn tnl-chart-view-btn--expand"
|
||||
title="펼쳐보기"
|
||||
aria-label="펼쳐보기"
|
||||
onClick={e => { e.stopPropagation(); onExpand(); }}
|
||||
>
|
||||
<IcExpand />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default TradeSignalChartCard;
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 매매 시그널 알림 행 — 캔들·지표 미니 차트 카드
|
||||
*/
|
||||
import React, {
|
||||
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { IndicatorConfig } from '../../types';
|
||||
import type {
|
||||
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
|
||||
} from '../../types';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { useChartRealtimeData } from '../../hooks/useChartRealtimeData';
|
||||
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { pinCandleWatch } from '../../utils/backendApi';
|
||||
import { timeframeToCandleType } from '../../utils/chartCandleType';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
timeframe: Timeframe;
|
||||
theme?: Theme;
|
||||
indicators?: IndicatorConfig[];
|
||||
enabled?: boolean;
|
||||
/** 전체보기 모드 — 부모 높이에 맞춤 */
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const TradeSignalMiniChart: React.FC<Props> = ({
|
||||
market,
|
||||
timeframe,
|
||||
theme = 'dark',
|
||||
indicators = [],
|
||||
enabled = true,
|
||||
fillHeight = false,
|
||||
}) => {
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartReloadTick, setChartReloadTick] = useState(0);
|
||||
const chartReloadTriggeredRef = useRef(false);
|
||||
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
||||
const barsMarketRef = useRef<string | null>(null);
|
||||
const chartLiveReadyRef = useRef(false);
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
|
||||
const handleCandlesReady = useCallback(() => {
|
||||
chartLiveReadyRef.current = true;
|
||||
const pending = pendingBarRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
|
||||
pendingBarRef.current = null;
|
||||
mgr.updateBar(pending);
|
||||
const wrap = canvasWrapRef.current;
|
||||
if (wrap && wrap.clientHeight > 0) mgr.resetPaneHeights(wrap.clientHeight);
|
||||
}, []);
|
||||
|
||||
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
|
||||
if (barsMarketRef.current !== marketRef.current) return;
|
||||
if (!chartLiveReadyRef.current || !managerRef.current) {
|
||||
pendingBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
if (append) managerRef.current.appendBar(bar);
|
||||
else managerRef.current.updateBar(bar);
|
||||
}, []);
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, false);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, true);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
|
||||
const { bars, barsMarket, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
timeframe,
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: enabled && useUpbit,
|
||||
source: 'BACKEND_STOMP' as const,
|
||||
}), [handleTickUpdate, handleNewCandle, enabled, useUpbit]),
|
||||
);
|
||||
|
||||
const { isLoadingMore } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe,
|
||||
isUpbit: useUpbit && enabled,
|
||||
managerRef,
|
||||
});
|
||||
|
||||
barsMarketRef.current = barsMarket;
|
||||
const barsReady = bars.length >= 2 && barsMarket === market;
|
||||
|
||||
const applyPaneHeights = useCallback(() => {
|
||||
const wrap = canvasWrapRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!wrap || wrap.clientHeight <= 0 || !mgr?.hasMainSeries()) return;
|
||||
mgr.resetPaneHeights(wrap.clientHeight);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
chartLiveReadyRef.current = false;
|
||||
pendingBarRef.current = null;
|
||||
chartReloadTriggeredRef.current = false;
|
||||
}, [market, timeframe, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const wrap = canvasWrapRef.current;
|
||||
if (!wrap) return;
|
||||
|
||||
const timers = [200, 600, 1200].map(delay =>
|
||||
setTimeout(() => {
|
||||
if (!canvasWrapRef.current) return;
|
||||
const h = canvasWrapRef.current.clientHeight;
|
||||
if (h <= 0) return;
|
||||
if (managerRef.current?.hasMainSeries()) {
|
||||
managerRef.current.resetPaneHeights(h);
|
||||
} else if (!chartReloadTriggeredRef.current && delay >= 600 && barsReady) {
|
||||
chartReloadTriggeredRef.current = true;
|
||||
setChartReloadTick(t => t + 1);
|
||||
}
|
||||
}, delay),
|
||||
);
|
||||
|
||||
const ro = new ResizeObserver(() => applyPaneHeights());
|
||||
ro.observe(wrap);
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [market, timeframe, enabled, barsReady, applyPaneHeights, fillHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !barsReady) return;
|
||||
if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) {
|
||||
chartReloadTriggeredRef.current = true;
|
||||
setChartReloadTick(t => t + 1);
|
||||
}
|
||||
}, [enabled, barsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const t = setTimeout(() => applyPaneHeights(), 80);
|
||||
return () => clearTimeout(t);
|
||||
}, [fillHeight, enabled, applyPaneHeights]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !useUpbit) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(timeframe));
|
||||
if (timeframeToCandleType(timeframe) !== '1m') {
|
||||
void pinCandleWatch(market, '1m');
|
||||
}
|
||||
}, [market, timeframe, enabled, useUpbit]);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className={`tnl-mini-chart-placeholder${fillHeight ? ' tnl-mini-chart-placeholder--fill' : ''}`}>
|
||||
차트 대기
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasWrapRef}
|
||||
className={[
|
||||
'tnl-mini-chart-canvas',
|
||||
'slot-chart-wrap',
|
||||
fillHeight ? 'tnl-mini-chart-canvas--fill' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{isLoading && <div className="tnl-mini-chart-loading">로딩…</div>}
|
||||
{isLoadingMore && (
|
||||
<div className="chart-history-loading">
|
||||
<div className="loading-spinner" style={{ width: 12, height: 12 }} />
|
||||
</div>
|
||||
)}
|
||||
<TradingChart
|
||||
key={`${market}-${timeframe}-${chartReloadTick}-${indicators.map(i => i.id).join(',')}`}
|
||||
chartVisible
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
timeframe={timeframe}
|
||||
chartType={'candlestick' as ChartType}
|
||||
theme={theme}
|
||||
mode={'chart' as ChartMode}
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[] as Drawing[]}
|
||||
logScale={false}
|
||||
drawingsLocked
|
||||
drawingsVisible={false}
|
||||
onCrosshair={noop as (d: LegendData | null) => void}
|
||||
onManagerReady={mgr => { managerRef.current = mgr; }}
|
||||
onAddDrawing={noop as (d: Drawing) => void}
|
||||
onCandlesReady={handleCandlesReady}
|
||||
magnifierEnabled={false}
|
||||
volumeVisible={false}
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={false}
|
||||
paneLayoutClamp
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeSignalMiniChart;
|
||||
@@ -0,0 +1,764 @@
|
||||
/* 매매 시그널 알림 목록 — 갤러리 행 (요약 카드 + 가로 스크롤 차트) */
|
||||
|
||||
.tnl-row--gallery {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tnl-row-gallery {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
height: 320px;
|
||||
max-height: 320px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */
|
||||
.tnl-summary-card {
|
||||
flex: 0 0 544px;
|
||||
width: 544px;
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #7aa2f7) 28%, var(--se-border, var(--border)));
|
||||
border-radius: 12px;
|
||||
background: var(--se-panel-card-bg, var(--bg2));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tnl-summary-card--buy {
|
||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
|
||||
}
|
||||
|
||||
.tnl-summary-card--sell {
|
||||
border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border)));
|
||||
}
|
||||
|
||||
.tnl-summary-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding: 14px 16px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-summary-panel:hover {
|
||||
background: color-mix(in srgb, var(--accent, #7aa2f7) 6%, transparent);
|
||||
}
|
||||
|
||||
/* 1행: 종목명 */
|
||||
.tnl-summary-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-summary-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tnl-summary-ko {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tnl-summary-sym {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text3, var(--se-text-muted));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tnl-summary-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-summary-dot {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 2행: 페어 · 시그널 가격 */
|
||||
.tnl-summary-quote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.tnl-summary-quote-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tnl-summary-arrow {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-summary-arrow--buy {
|
||||
color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.tnl-summary-arrow--sell {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.tnl-summary-pair {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text3, var(--se-text-muted));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tnl-summary-type {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tnl-summary-type--buy {
|
||||
color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.tnl-summary-type--sell {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.tnl-summary-quote-right {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tnl-summary-price {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tnl-summary-price--buy {
|
||||
color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.tnl-summary-price--sell {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
/* 3행+: 상세 정보 */
|
||||
.tnl-summary-meta {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.tnl-summary-info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.tnl-summary-info-lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text3, var(--se-text-muted));
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-summary-info-val {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tnl-summary-info-val--hi {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 하단: 투자전략 (vtd-target-strategy-field) */
|
||||
.tnl-summary-strategy-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tnl-summary-strategy-lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text3, var(--se-text-muted));
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-summary-strategy-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
background: var(--bg, var(--bg3));
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tnl-summary-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg3, var(--se-center-bg));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-row-gallery--chart-expanded .tnl-charts-scroll {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tnl-charts-expanded {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-charts-expanded .tnl-chart-card--expanded {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
gap: 8px;
|
||||
padding: 0 4px 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-charts-scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
||||
}
|
||||
|
||||
.tnl-charts-scroll::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.tnl-charts-scroll::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text3) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tnl-charts-track {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.tnl-chart-card {
|
||||
position: relative;
|
||||
flex: 0 0 300px;
|
||||
width: 300px;
|
||||
height: 280px;
|
||||
max-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
border-radius: 10px;
|
||||
background: var(--se-panel-card-bg, var(--bg2));
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-chart-card-body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tnl-chart-card--expanded .tnl-chart-card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tnl-chart-card--placeholder {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tnl-chart-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-chart-card-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tnl-chart-card-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tnl-mini-chart-canvas {
|
||||
position: relative;
|
||||
flex: 0 0 236px;
|
||||
height: 236px;
|
||||
max-height: 236px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tnl-mini-chart-canvas--fill {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.tnl-mini-chart-canvas > .tv-chart-wrap {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.tnl-mini-chart-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
background: color-mix(in srgb, var(--bg2) 80%, transparent);
|
||||
}
|
||||
|
||||
.tnl-mini-chart-placeholder {
|
||||
flex: 0 0 236px;
|
||||
height: 236px;
|
||||
max-height: 236px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.tnl-mini-chart-placeholder--fill {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* 차트 하단 우측 — 펼쳐보기 / 원래보기 */
|
||||
.tnl-chart-view-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg2) 88%, transparent);
|
||||
color: var(--accent, #3f7ef5);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--bg) 50%, transparent);
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.tnl-chart-view-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent, #3f7ef5) 18%, var(--bg2));
|
||||
border-color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.tnl-chart-view-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.tnl-chart-view-btn--restore {
|
||||
color: var(--text2);
|
||||
border-color: var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--bg3) 92%, transparent);
|
||||
}
|
||||
|
||||
.tnl-chart-view-btn--restore:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent, #7aa2f7);
|
||||
background: color-mix(in srgb, var(--accent, #7aa2f7) 12%, var(--bg3));
|
||||
}
|
||||
|
||||
.tnl-muted {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tnl-list--gallery {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 헤더 — 제목(좌) · 목록/그리드 전환(우) */
|
||||
.tnl-header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tnl-header-intro {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tnl-header-intro .tnl-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tnl-header-intro .tnl-sub {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tnl-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.tnl-layout-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 그리드 n×n 배치 선택 */
|
||||
.tnl-grid-layout-picker {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-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;
|
||||
}
|
||||
|
||||
.tnl-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));
|
||||
}
|
||||
|
||||
.tnl-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);
|
||||
}
|
||||
|
||||
.tnl-grid-layout-trigger--disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tnl-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);
|
||||
}
|
||||
|
||||
.tnl-grid-layout-popover-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text3);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tnl-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);
|
||||
}
|
||||
|
||||
.tnl-grid-layout-group:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.tnl-grid-layout-group-lbl {
|
||||
flex: 0 0 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tnl-grid-layout-group-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tnl-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;
|
||||
}
|
||||
|
||||
.tnl-grid-preset-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tnl-grid-preset-btn--on {
|
||||
border-color: var(--accent, #3f7ef5);
|
||||
background: color-mix(in srgb, var(--accent, #3f7ef5) 14%, transparent);
|
||||
color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.tnl-grid-preset-icon-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 그리드형 — 목록형과 동일한 알림 상세 카드 · n×n */
|
||||
.tnl-list-wrap.tnl-list-wrap--grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 24px 24px;
|
||||
box-sizing: border-box;
|
||||
container-type: inline-size;
|
||||
container-name: tnl-grid-wrap;
|
||||
}
|
||||
|
||||
ul.tnl-list.tnl-list--grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
flex-direction: unset;
|
||||
grid-auto-rows: auto;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 4px 16px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@container tnl-grid-wrap (max-width: 640px) {
|
||||
ul.tnl-list.tnl-list--grid {
|
||||
grid-template-columns: minmax(0, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@container tnl-grid-wrap (max-width: 960px) {
|
||||
ul.tnl-list.tnl-list--grid.tnl-grid--cols-3,
|
||||
ul.tnl-list.tnl-list--grid.tnl-grid--cols-4,
|
||||
ul.tnl-list.tnl-list--grid.tnl-grid--cols-5 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@container tnl-grid-wrap (max-width: 1200px) {
|
||||
ul.tnl-list.tnl-list--grid.tnl-grid--cols-4,
|
||||
ul.tnl-list.tnl-list--grid.tnl-grid--cols-5 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tnl-row.tnl-row--grid {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 목록형과 동일 카드 박스(544×300) — 그리드 셀 너비에 맞춤 */
|
||||
.tnl-row--grid .tnl-summary-card,
|
||||
.tnl-summary-card--grid {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.tnl-row--grid.tnl-row--unread .tnl-summary-card {
|
||||
border-color: rgba(122, 162, 247, 0.45);
|
||||
}
|
||||
|
||||
.tnl-row--grid .tnl-summary-card--selected,
|
||||
.tnl-row--grid.tnl-row--selected .tnl-summary-card {
|
||||
border-color: var(--accent, #7aa2f7);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
||||
}
|
||||
|
||||
.tnl-row--gallery.tnl-row--selected .tnl-summary-card,
|
||||
.tnl-row--gallery.tnl-row--selected .tnl-charts-scroll,
|
||||
.tnl-row--gallery.tnl-row--selected .tnl-charts-expanded {
|
||||
border-color: var(--accent, #7aa2f7);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
||||
}
|
||||
|
||||
@container tnl-main (max-width: 900px) {
|
||||
.tnl-row-gallery {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.tnl-summary-card {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.tnl-charts-scroll,
|
||||
.tnl-charts-expanded {
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ export interface UiPreferences {
|
||||
tradeNotifications?: {
|
||||
readIds?: string[];
|
||||
hiddenIds?: string[];
|
||||
/** 매매 시그널 알림 목록 화면 — list | grid */
|
||||
listLayout?: 'list' | 'grid';
|
||||
/** 그리드형 열 배치 프리셋 id (tradeNotificationGridPresets) */
|
||||
gridPreset?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 매매 시그널 알림 — 그리드형 n×n 레이아웃 프리셋 (TradingView 스타일) */
|
||||
|
||||
export type TradeNotificationGridPresetId =
|
||||
| '1x1'
|
||||
| '2x1'
|
||||
| '1x2'
|
||||
| '3x1'
|
||||
| '1x3'
|
||||
| '2x1-31'
|
||||
| '2x2'
|
||||
| '4x1'
|
||||
| '1x4'
|
||||
| '2x1-211'
|
||||
| '5x1'
|
||||
| '3x2'
|
||||
| '2x3'
|
||||
| '4x2'
|
||||
| '2x4';
|
||||
|
||||
export interface TradeNotificationGridPreset {
|
||||
id: TradeNotificationGridPresetId;
|
||||
/** 스크롤 목록에 적용할 열 수 */
|
||||
cols: number;
|
||||
/** 팝업 그룹 라벨 (패널 수) */
|
||||
group: number;
|
||||
/** 짧은 표기 (2×2 등) */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const TRADE_NOTIFICATION_GRID_PRESETS: TradeNotificationGridPreset[] = [
|
||||
{ id: '1x1', cols: 1, group: 1, label: '1×1' },
|
||||
{ id: '2x1', cols: 2, group: 2, label: '2×1' },
|
||||
{ id: '1x2', cols: 1, group: 2, label: '1×2' },
|
||||
{ id: '3x1', cols: 3, group: 3, label: '3×1' },
|
||||
{ id: '1x3', cols: 1, group: 3, label: '1×3' },
|
||||
{ id: '2x1-31', cols: 2, group: 3, label: '2+1' },
|
||||
{ id: '2x2', cols: 2, group: 4, label: '2×2' },
|
||||
{ id: '4x1', cols: 4, group: 4, label: '4×1' },
|
||||
{ id: '1x4', cols: 1, group: 4, label: '1×4' },
|
||||
{ id: '2x1-211', cols: 2, group: 4, label: '2+2' },
|
||||
{ id: '5x1', cols: 5, group: 5, label: '5×1' },
|
||||
{ id: '3x2', cols: 3, group: 6, label: '3×2' },
|
||||
{ id: '2x3', cols: 2, group: 6, label: '2×3' },
|
||||
{ id: '4x2', cols: 4, group: 8, label: '4×2' },
|
||||
{ id: '2x4', cols: 2, group: 8, label: '2×4' },
|
||||
];
|
||||
|
||||
const PRESET_MAP = new Map(
|
||||
TRADE_NOTIFICATION_GRID_PRESETS.map(p => [p.id, p]),
|
||||
);
|
||||
|
||||
export const DEFAULT_TRADE_NOTIFICATION_GRID_PRESET: TradeNotificationGridPresetId = '2x2';
|
||||
|
||||
export function getTradeNotificationGridPreset(
|
||||
id: string | undefined | null,
|
||||
): TradeNotificationGridPreset {
|
||||
const preset = id ? PRESET_MAP.get(id as TradeNotificationGridPresetId) : undefined;
|
||||
return preset ?? PRESET_MAP.get(DEFAULT_TRADE_NOTIFICATION_GRID_PRESET)!;
|
||||
}
|
||||
|
||||
export function normalizeTradeNotificationGridPreset(
|
||||
id: string | undefined | null,
|
||||
): TradeNotificationGridPresetId {
|
||||
return getTradeNotificationGridPreset(id).id;
|
||||
}
|
||||
|
||||
/** 그룹별 프리셋 (팝업 행 구성) */
|
||||
export function gridPresetsByGroup(): Map<number, TradeNotificationGridPreset[]> {
|
||||
const map = new Map<number, TradeNotificationGridPreset[]>();
|
||||
for (const p of TRADE_NOTIFICATION_GRID_PRESETS) {
|
||||
const list = map.get(p.group) ?? [];
|
||||
list.push(p);
|
||||
map.set(p.group, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
import {
|
||||
DEFAULT_TRADE_NOTIFICATION_GRID_PRESET,
|
||||
normalizeTradeNotificationGridPreset,
|
||||
type TradeNotificationGridPresetId,
|
||||
} from './tradeNotificationGridPresets';
|
||||
|
||||
export type TradeNotificationListLayout = 'list' | 'grid';
|
||||
|
||||
export function loadTradeNotificationListLayout(): TradeNotificationListLayout {
|
||||
const v = getUiPreferences().tradeNotifications?.listLayout;
|
||||
return v === 'grid' ? 'grid' : 'list';
|
||||
}
|
||||
|
||||
export function saveTradeNotificationListLayout(layout: TradeNotificationListLayout): void {
|
||||
patchUiPreferences({ tradeNotifications: { listLayout: layout } });
|
||||
}
|
||||
|
||||
export function loadTradeNotificationGridPreset(): TradeNotificationGridPresetId {
|
||||
return normalizeTradeNotificationGridPreset(
|
||||
getUiPreferences().tradeNotifications?.gridPreset ?? DEFAULT_TRADE_NOTIFICATION_GRID_PRESET,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveTradeNotificationGridPreset(preset: TradeNotificationGridPresetId): void {
|
||||
patchUiPreferences({ tradeNotifications: { gridPreset: preset } });
|
||||
}
|
||||
Reference in New Issue
Block a user