매매 시그널 알림 화면 수정
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user