매매 시그널 알림 화면 수정
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 { Theme, TradeOrderFillRequest } from '../types';
|
||||||
import type { TickerData } from '../hooks/useMarketTicker';
|
import type { TickerData } from '../hooks/useMarketTicker';
|
||||||
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
||||||
import { buildSignalDetailRows, formatSignalPrice, getSignalHeadline } from '../utils/tradeSignalDisplay';
|
|
||||||
import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
|
import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
|
||||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||||
import TradeOrderPanel from './TradeOrderPanel';
|
import TradeOrderPanel from './TradeOrderPanel';
|
||||||
@@ -14,6 +13,20 @@ import PaperSplitPanel from './paper/PaperSplitPanel';
|
|||||||
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
||||||
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
||||||
import '../styles/virtualTradingDashboard.css';
|
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';
|
type RightTab = 'trade' | 'orderbook';
|
||||||
|
|
||||||
@@ -69,6 +82,12 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
} = useTradeNotification();
|
} = useTradeNotification();
|
||||||
|
|
||||||
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
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 [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||||
const [selectedNotifyId, setSelectedNotifyId] = useState<string | null>(null);
|
const [selectedNotifyId, setSelectedNotifyId] = useState<string | null>(null);
|
||||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||||
@@ -172,15 +191,57 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
? allNotifications.filter(n => !n.isRead)
|
? allNotifications.filter(n => !n.isRead)
|
||||||
: allNotifications;
|
: 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 (
|
return (
|
||||||
<div className={`tnl-page tnl-page--with-right bps-page--vtd app ${theme}`}>
|
<div className={`tnl-page tnl-page--with-right bps-page--vtd app ${theme}`}>
|
||||||
<div className="tnl-body">
|
<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">
|
||||||
|
<div className="tnl-header-row">
|
||||||
|
<div className="tnl-header-intro">
|
||||||
<h1 className="tnl-title">매매 시그널 알림</h1>
|
<h1 className="tnl-title">매매 시그널 알림</h1>
|
||||||
<p className="tnl-sub">
|
<p className="tnl-sub">
|
||||||
알림을 선택하면 우측 매매 패널에 종목·가격이 자동 입력됩니다.
|
{isListView
|
||||||
|
? '각 알림 왼쪽은 요약, 오른쪽은 캔들·지표 차트입니다. 차트 영역은 가로 스크롤로 확인할 수 있습니다.'
|
||||||
|
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 우측 아이콘으로 배치를 변경할 수 있습니다.`}
|
||||||
</p>
|
</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">
|
<div className="tnl-toolbar">
|
||||||
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}건</span>
|
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}건</span>
|
||||||
<div className="tnl-filter">
|
<div className="tnl-filter">
|
||||||
@@ -231,76 +292,38 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tnl-list-wrap">
|
<div className={['tnl-list-wrap', !isListView ? 'tnl-list-wrap--grid' : ''].filter(Boolean).join(' ')}>
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="tnl-empty">표시할 알림이 없습니다.</div>
|
<div className="tnl-empty">표시할 알림이 없습니다.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="tnl-list">
|
<ul
|
||||||
{filtered.map(item => {
|
className={[
|
||||||
const isBuy = item.signalType === 'BUY';
|
'tnl-list',
|
||||||
const detailRows = buildSignalDetailRows(item);
|
isListView ? 'tnl-list--gallery' : 'tnl-list--grid',
|
||||||
const isSelected = selectedNotifyId === item.id;
|
!isListView ? `tnl-grid--cols-${gridCols}` : '',
|
||||||
return (
|
].filter(Boolean).join(' ')}
|
||||||
<li
|
style={!isListView ? { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` } : undefined}
|
||||||
|
>
|
||||||
|
{filtered.map(item => (
|
||||||
|
<TradeNotificationListRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`tnl-row ${!item.isRead ? 'tnl-row--unread' : ''}${isSelected ? ' tnl-row--selected' : ''}`}
|
item={item}
|
||||||
>
|
theme={theme}
|
||||||
<button
|
layoutMode={listLayout}
|
||||||
type="button"
|
isSelected={selectedNotifyId === item.id}
|
||||||
className="tnl-row-main"
|
chartsEnabled={isListView}
|
||||||
onClick={() => handleNotificationSelect(item)}
|
onSelect={() => handleNotificationSelect(item)}
|
||||||
>
|
onDelete={e => void handleDeleteOne(item, e)}
|
||||||
<span className={`tnl-signal ${isBuy ? 'tnl-signal--buy' : 'tnl-signal--sell'}`}>
|
onDetail={() => {
|
||||||
{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={() => {
|
|
||||||
if (!item.isRead) markAsRead(item.id);
|
if (!item.isRead) markAsRead(item.id);
|
||||||
openDetail(item, { centered: true });
|
openDetail(item, { centered: true });
|
||||||
}}
|
}}
|
||||||
>
|
onGoToChart={() => {
|
||||||
상세
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tnl-row-chart"
|
|
||||||
title="차트로 이동"
|
|
||||||
onClick={() => {
|
|
||||||
markAsRead(item.id);
|
markAsRead(item.id);
|
||||||
onGoToChart(item.market);
|
onGoToChart(item.market);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
차트
|
))}
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ interface TradingChartProps {
|
|||||||
chartVisible?: boolean;
|
chartVisible?: boolean;
|
||||||
/** pane(캔들·거래량·보조지표) 구분선 */
|
/** pane(캔들·거래량·보조지표) 구분선 */
|
||||||
paneSeparatorOptions?: ChartPaneSeparatorOptions;
|
paneSeparatorOptions?: ChartPaneSeparatorOptions;
|
||||||
|
/** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
|
||||||
|
paneLayoutClamp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TradingChart: React.FC<TradingChartProps> = ({
|
const TradingChart: React.FC<TradingChartProps> = ({
|
||||||
@@ -162,6 +164,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
seriesPriceLabelsEnabled = true,
|
seriesPriceLabelsEnabled = true,
|
||||||
chartVisible = true,
|
chartVisible = true,
|
||||||
paneSeparatorOptions,
|
paneSeparatorOptions,
|
||||||
|
paneLayoutClamp = false,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||||
@@ -325,13 +328,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
required = mgr.resetPaneHeights(wrapperH);
|
required = mgr.resetPaneHeights(wrapperH);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (required > wrapperH + 4) {
|
if (!paneLayoutClamp && required > wrapperH + 4) {
|
||||||
container.style.height = `${required}px`;
|
container.style.height = `${required}px`;
|
||||||
} else {
|
} else {
|
||||||
container.style.height = '';
|
container.style.height = '';
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [paneLayoutClamp]);
|
||||||
|
|
||||||
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
|
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
|
||||||
useEffect(() => {
|
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?: {
|
tradeNotifications?: {
|
||||||
readIds?: string[];
|
readIds?: string[];
|
||||||
hiddenIds?: 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