Files
goldenChart/frontend/src/components/Toolbar.tsx
T
2026-06-16 08:53:35 +09:00

918 lines
38 KiB
TypeScript

import React, { useRef, useEffect, useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
import IndicatorDropdownPanel from './IndicatorDropdownPanel';
import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types';
import { MarketSearchPanel } from './MarketSearchPanel';
import { getKoreanName } from '../utils/marketNameCache';
import {
formatChartLiveChangePct,
formatChartLivePrice,
type ChartLiveQuote,
} from '../utils/chartLiveQuote';
import LayoutPicker from './LayoutPicker';
import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { loadStrategies } from '../utils/backendApi';
import type { TickerData, MarketInfo } from '../hooks/useMarketTicker';
interface BtStrategy { id?: number; name: string; source: 'db'; buyCondition?: unknown; sellCondition?: unknown; }
function loadBtStrategies(): Promise<BtStrategy[]> {
return loadStrategies()
.then(list => (list ?? []).map(s => ({
id: s.id,
name: s.name,
source: 'db' as const,
buyCondition: s.buyCondition,
sellCondition: s.sellCondition,
})))
.catch(() => []);
}
export interface ToolbarProps {
symbol: string;
timeframe: Timeframe;
chartType: ChartType;
theme: Theme;
mode: ChartMode;
logScale: boolean;
showGrid: boolean;
activeIndicators: IndicatorConfig[];
onSymbol: (s: string) => void;
onTimeframe: (t: Timeframe) => void;
onChartType: (t: ChartType) => void;
onTheme: () => void;
onMode: () => void;
onLogScale: () => void;
onToggleGrid: () => void;
onFitContent: () => void;
onScrollToNow?: () => void;
onAddIndicator: (type: string) => void;
onAddIndicators?: (types: string[]) => void;
/** 탭 목록 순서 변경 → 차트 pane 순서 동기화 */
onIndicatorOrderChange?: (orderedTypes: string[]) => void;
onRemoveByType: (type: string) => void;
onAutoFib: () => void;
onClearFib: () => void;
onScreenshot: () => void;
onToggleStats: () => void;
onToggleWatch: () => void;
onToggleAlert: () => void;
/** 매매 시그널 알림 (미확인 개수) */
tradeNotifyUnread?: number;
onOpenTradeNotifications?: () => void;
/** 자석모드 현재 상태 */
magnetMode?: 'off' | 'weak' | 'strong';
/** 자석모드 토글 (off↔strong) */
onToggleMagnet?: () => void;
onUndo?: () => void;
onRedo?: () => void;
canUndo?: boolean;
canRedo?: boolean;
onClearDrawings?: () => void;
layoutId?: string;
syncOptions?: SyncOptions;
onLayoutSelect?: (def: LayoutDef) => void;
onSyncChange?: (key: keyof SyncOptions, val: boolean) => void;
/** 돋보기(확대경) 활성 여부 */
magnifierActive?: boolean;
/** 돋보기 토글 */
onToggleMagnifier?: () => void;
/** 좌측 마켓 패널 활성 여부 */
marketPanelActive?: boolean;
/** 마켓 패널 토글 */
onToggleMarketPanel?: () => void;
/** useMarketTicker 공유 시세 (종목검색·마켓패널 통합) */
marketTickers?: Map<string, TickerData>;
marketInfos?: MarketInfo[];
marketLoading?: boolean;
refreshAllTickers?: () => Promise<void>;
onMarketSearchOpenChange?: (open: boolean) => void;
/** 백테스팅 드롭다운 표시 여부 */
backtestActive?: boolean;
/** 백테스팅 실행 콜백 */
onRunBacktest?: (opts: { strategyId?: number; buyCondition?: unknown; sellCondition?: unknown; strategyName?: string }) => void;
/** 백테스팅 실행 중 여부 */
backtestRunning?: boolean;
/** 현재 실행 중인 전략 ID */
backtestRunningId?: number | string;
/** 백테스팅 설정 버튼 클릭 콜백 */
onOpenBtSettings?: () => void;
/** 백테스팅 결과보기 버튼 클릭 콜백 */
onOpenBtResults?: () => void;
/** 현재 실행된 결과 존재 여부 (결과보기 버튼 강조) */
hasBtResult?: boolean;
/** 매수/매도 마커 삭제 콜백 */
onClearBtMarkers?: () => void;
/** 실시간 전략 체크 패널 토글 콜백 */
onToggleLiveStrategy?: () => void;
/** 실시간 전략 패널 열림 여부 */
liveStrategyActive?: boolean;
/** 보조지표 일괄 설정 팝업 */
onOpenBulkIndicatorSettings?: () => void;
/** 지표 추가 팝업 항목 — 해당 지표 설정 모달 */
onOpenIndicatorSettings?: (type: string) => void;
/** 종목 선택 우측 시세 (실시간 차트 — 하단 upbit-bar 대체) */
showChartQuote?: boolean;
chartLiveQuote?: ChartLiveQuote | null;
chartQuoteLoading?: boolean;
chartQuoteError?: string | null;
/** 9·20일 신고가·신저가 차트 표시 (실시간 차트 상단 툴바) */
priceExtremeLabelsVisible?: boolean;
onTogglePriceExtremeLabels?: () => void;
/** 매매 알림 팝업 표시 위치·방식 */
tradeAlertPopupPosition?: string;
tradeAlertPopupLayout?: string;
tradeAlertPopupGridCols?: number;
onTradeAlertPopupPosition?: (v: import('../utils/tradeAlertPopupLayout').TradeAlertPopupPosition) => void;
onTradeAlertPopupLayout?: (v: import('../utils/tradeAlertPopupLayout').TradeAlertPopupLayout) => void;
onTradeAlertPopupGridCols?: (v: number) => void;
}
// ─── SVG 아이콘 ────────────────────────────────────────────────────────────
const IcSearch = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
</svg>
);
const IcPlus = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.8">
<line x1="7" y1="2" x2="7" y2="12"/><line x1="2" y1="7" x2="12" y2="7"/>
</svg>
);
const IcCandlestick = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="4" y1="2" x2="4" y2="5"/><rect x="2" y="5" width="4" height="5" rx="0.5" fill="currentColor" opacity="0.3"/><line x1="4" y1="10" x2="4" y2="14"/>
<line x1="12" y1="4" x2="12" y2="7"/><rect x="10" y="7" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.3"/><line x1="12" y1="11" x2="12" y2="13"/>
</svg>
);
const IcLine = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<polyline points="1,13 5,8 9,10 15,3"/>
</svg>
);
const IcArea = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<polyline points="1,13 5,7 9,9 15,3"/><path d="M1,13 L5,7 L9,9 L15,3 L15,14 L1,14 Z" fill="currentColor" opacity="0.2"/>
</svg>
);
const IcBar = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="4" y1="3" x2="4" y2="13"/><line x1="4" y1="6" x2="2" y2="6"/><line x1="4" y1="10" x2="6" y2="10"/>
<line x1="12" y1="5" x2="12" y2="13"/><line x1="12" y1="7" x2="10" y2="7"/><line x1="12" y1="11" x2="14" y2="11"/>
</svg>
);
const IcIndicator = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<polyline points="1,12 4,7 7,9 10,5 13,8 16,4"/>
<circle cx="4" cy="7" r="1.5" fill="currentColor"/>
<circle cx="10" cy="5" r="1.5" fill="currentColor"/>
</svg>
);
/** 보조지표 일괄 설정 (막대 + 슬라이더) */
const IcIndBulkSettings = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3">
<rect x="2" y="9" width="2.5" height="5" rx="0.3" fill="currentColor" opacity="0.35"/>
<rect x="6" y="6" width="2.5" height="8" rx="0.3" fill="currentColor" opacity="0.5"/>
<rect x="10" y="3" width="2.5" height="11" rx="0.3" fill="currentColor" opacity="0.7"/>
<line x1="1" y1="14" x2="15" y2="14"/>
<circle cx="12" cy="2" r="1.2" fill="currentColor" stroke="none"/>
</svg>
);
const IcUndo = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 7 C3 4 5 2 8 2 C11 2 13 4 13 7 C13 10 11 12 8 12"/>
<polyline points="1,5 3,7 5,5"/>
</svg>
);
const IcRedo = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M11 7 C11 4 9 2 6 2 C3 2 1 4 1 7 C1 10 3 12 6 12"/>
<polyline points="13,5 11,7 9,5"/>
</svg>
);
const IcCamera = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 5 Q1 4 2 4 L4 4 L5 2 L11 2 L12 4 L14 4 Q15 4 15 5 L15 13 Q15 14 14 14 L2 14 Q1 14 1 13 Z"/>
<circle cx="8" cy="9" r="2.5"/>
</svg>
);
const IcBell = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M7.5 2 C5 2 3 4 3 6.5 L3 10 L2 11 L13 11 L12 10 L12 6.5 C12 4 10 2 7.5 2 Z"/>
<line x1="6" y1="12" x2="9" y2="12"/>
<circle cx="7.5" cy="13" r="0.8" fill="currentColor" stroke="none"/>
</svg>
);
/** 매매 시그널 알림 (번개 아이콘으로 가격 알림 벨과 구분) */
const IcTradeSignal = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8.5 1.5 L4 8.5 H7.5 L6 13.5 L11 6 H7.5 Z" fill="currentColor" stroke="none" opacity="0.85"/>
</svg>
);
const IcPriceExtreme = () => (
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
<path d="M7 1.5 L10.5 6 H3.5 Z" fill="#4dabf7"/>
<path d="M7 12.5 L3.5 8 H10.5 Z" fill="#ef5350"/>
</svg>
);
const IcGridIcon = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.3">
<rect x="1" y="1" width="13" height="13" rx="1"/>
<line x1="5.5" y1="1" x2="5.5" y2="14"/>
<line x1="9.5" y1="1" x2="9.5" y2="14"/>
<line x1="1" y1="5.5" x2="14" y2="5.5"/>
<line x1="1" y1="9.5" x2="14" y2="9.5"/>
</svg>
);
const IcStats = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4">
<rect x="1" y="9" width="3" height="5" rx="0.5" fill="currentColor" opacity="0.5"/>
<rect x="6" y="5" width="3" height="9" rx="0.5" fill="currentColor" opacity="0.5"/>
<rect x="11" y="2" width="3" height="12" rx="0.5" fill="currentColor" opacity="0.5"/>
</svg>
);
const IcFibAuto = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.2">
<line x1="1" y1="2" x2="14" y2="2" strokeOpacity="0.9"/>
<line x1="1" y1="5.5" x2="14" y2="5.5" strokeOpacity="0.6"/>
<line x1="1" y1="7.5" x2="14" y2="7.5"/>
<line x1="1" y1="9.5" x2="14" y2="9.5" strokeOpacity="0.6"/>
<line x1="1" y1="13" x2="14" y2="13" strokeOpacity="0.9"/>
<circle cx="2" cy="2" r="1.2" fill="currentColor"/>
<circle cx="2" cy="13" r="1.2" fill="currentColor"/>
</svg>
);
const IcMagnet = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 2 L3 8 C3 10.8 5.2 13 8 13 C10.8 13 13 10.8 13 8 L13 2"/>
<line x1="1" y1="2" x2="5" y2="2"/>
<line x1="11" y1="2" x2="15" y2="2"/>
</svg>
);
const IcList = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4">
<line x1="5" y1="3.5" x2="14" y2="3.5"/>
<line x1="5" y1="7.5" x2="14" y2="7.5"/>
<line x1="5" y1="11.5" x2="14" y2="11.5"/>
<circle cx="2.5" cy="3.5" r="1.2" fill="currentColor" stroke="none"/>
<circle cx="2.5" cy="7.5" r="1.2" fill="currentColor" stroke="none"/>
<circle cx="2.5" cy="11.5" r="1.2" fill="currentColor" stroke="none"/>
</svg>
);
const IcReplay = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="7.5" cy="7.5" r="6"/>
<polyline points="7.5,4 7.5,7.5 10,9"/>
</svg>
);
// ─── 차트 타입 아이콘 매핑 ─────────────────────────────────────────────────
const ChartIcon: Record<ChartType, React.ReactNode> = {
candlestick: <IcCandlestick />,
bar: <IcBar />,
line: <IcLine />,
area: <IcArea />,
baseline: <IcLine />,
};
const TIMEFRAMES: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
const CHART_TYPES: { value: ChartType; label: string }[] = [
{ value: 'candlestick', label: '캔들스틱' },
{ value: 'bar', label: '바' },
{ value: 'line', label: '라인' },
{ value: 'area', label: '영역' },
{ value: 'baseline', label: '기준선' },
];
// ─── Toolbar ──────────────────────────────────────────────────────────────
const Toolbar: React.FC<ToolbarProps> = ({
symbol, timeframe, chartType, theme, mode, logScale, showGrid,
activeIndicators,
onSymbol, onTimeframe, onChartType, onTheme, onMode,
onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onIndicatorOrderChange, onRemoveByType,
onAutoFib, onClearFib, onScreenshot,
onToggleStats, onToggleWatch, onToggleAlert,
tradeNotifyUnread = 0, onOpenTradeNotifications,
magnetMode = 'off', onToggleMagnet,
onUndo, onRedo, canUndo, canRedo, onClearDrawings,
onToggleGrid,
layoutId = '1', syncOptions = DEFAULT_SYNC, onLayoutSelect, onSyncChange,
magnifierActive = false, onToggleMagnifier,
marketPanelActive = false, onToggleMarketPanel,
marketTickers, marketInfos, marketLoading, refreshAllTickers,
onMarketSearchOpenChange,
backtestActive = false, onRunBacktest,
backtestRunning = false, backtestRunningId,
onOpenBtSettings,
onOpenBtResults,
hasBtResult = false,
onClearBtMarkers,
onToggleLiveStrategy,
liveStrategyActive = false,
onOpenBulkIndicatorSettings,
onOpenIndicatorSettings,
showChartQuote = false,
chartLiveQuote = null,
chartQuoteLoading = false,
chartQuoteError = null,
priceExtremeLabelsVisible = false,
onTogglePriceExtremeLabels,
tradeAlertPopupPosition,
tradeAlertPopupLayout,
tradeAlertPopupGridCols,
onTradeAlertPopupPosition,
onTradeAlertPopupLayout,
onTradeAlertPopupGridCols,
}) => {
const [showMarket, setShowMarket] = React.useState(false);
const [showTfMenu, setShowTfMenu] = React.useState(false);
const [showCtMenu, setShowCtMenu] = React.useState(false);
const [showIndMenu,setShowIndMenu]= React.useState(false);
const [showLayout, setShowLayout] = useState(false);
const [layoutPopupPos, setLayoutPopupPos] = useState<{ top: number; right: number } | null>(null);
const layoutBtnRef = useRef<HTMLButtonElement>(null);
// 백테스팅 드롭다운
const [showBtDrop, setShowBtDrop] = useState(false);
const [btStrategies, setBtStrategies] = useState<BtStrategy[]>([]);
const [btLoading, setBtLoading] = useState(false);
const btBtnRef = useRef<HTMLButtonElement>(null);
const btDropRef = useRef<HTMLDivElement>(null);
const openBtDrop = useCallback(async () => {
if (showBtDrop) { setShowBtDrop(false); return; }
setBtLoading(true);
setShowBtDrop(true);
try {
const list = await loadBtStrategies();
setBtStrategies(list);
} finally {
setBtLoading(false);
}
}, [showBtDrop]);
// 바깥 클릭 시 닫기
useEffect(() => {
if (!showBtDrop) return;
const handler = (e: MouseEvent) => {
if (
btBtnRef.current && btBtnRef.current.contains(e.target as Node) ||
btDropRef.current && btDropRef.current.contains(e.target as Node)
) return;
setShowBtDrop(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showBtDrop]);
const handleLayoutBtnClick = useCallback(() => {
if (showLayout) { setShowLayout(false); setLayoutPopupPos(null); return; }
const rect = layoutBtnRef.current?.getBoundingClientRect();
if (rect) {
setLayoutPopupPos({
top: rect.bottom + 4,
right: window.innerWidth - rect.right,
});
}
setShowLayout(true);
}, [showLayout]);
const openMarketSearch = useCallback(() => {
setShowMarket(v => {
const next = !v;
onMarketSearchOpenChange?.(next);
if (next) void refreshAllTickers?.();
return next;
});
setShowTfMenu(false);
setShowCtMenu(false);
setShowIndMenu(false);
}, [onMarketSearchOpenChange, refreshAllTickers]);
const closeMarketSearch = useCallback(() => {
setShowMarket(false);
onMarketSearchOpenChange?.(false);
}, [onMarketSearchOpenChange]);
return (
<>
{/* 종목 검색 패널 */}
{showMarket && (
<MarketSearchPanel
currentMarket={symbol}
onSelect={s => { onSymbol(s); closeMarketSearch(); }}
onClose={closeMarketSearch}
tickers={marketTickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
refreshAllTickers={refreshAllTickers}
/>
)}
<div className="tv-toolbar">
{/* ── Left ─────────────────────────────────────────────────────────── */}
<div className="tv-toolbar-left">
{/* 종목 검색(아이콘) · 선택 종목 표시 — 클릭 시 MarketSearchPanel */}
<div className="tv-sym-wrap">
<button
type="button"
className="tv-sym-btn tv-sym-btn--search"
onClick={openMarketSearch}
title="종목 검색"
aria-expanded={showMarket}
aria-haspopup="dialog"
>
<IcSearch />
</button>
</div>
<div className="tv-toolbar-quote" aria-label="선택 종목">
<button
type="button"
className="tv-quote-ko"
onClick={openMarketSearch}
title="종목 변경"
aria-expanded={showMarket}
>
{getKoreanName(symbol)}
</button>
<span className="tv-quote-en">{symbol}</span>
{showChartQuote && chartQuoteLoading && (
<span className="tv-quote-status">데이터 로딩 중…</span>
)}
{showChartQuote && chartQuoteError && (
<span className="tv-quote-status tv-quote-status--err"> {chartQuoteError}</span>
)}
{showChartQuote && !chartQuoteLoading && !chartQuoteError && chartLiveQuote?.price != null && (
<>
<span
className="tv-quote-price"
style={{ color: chartLiveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
>
{formatChartLivePrice(chartLiveQuote.price)}
</span>
{chartLiveQuote.changePct != null && (
<span className={`tv-quote-change ${chartLiveQuote.isUp ? 'up' : 'down'}`}>
{formatChartLiveChangePct(chartLiveQuote.changePct)}
</span>
)}
</>
)}
</div>
{/* Compare */}
<button className="tv-icon-btn" title="워치리스트" onClick={onToggleWatch}>
<IcPlus />
</button>
<div className="tv-sep" />
{/* Timeframe Dropdown */}
<div className="tv-dropdown-wrap">
<button
className="tv-tf-btn"
onClick={() => { setShowTfMenu(v => !v); setShowCtMenu(false); setShowIndMenu(false); }}
title="시간봉 변경"
>
{timeframe}
<svg width="8" height="5" viewBox="0 0 8 5" fill="currentColor" style={{ marginLeft: 4 }}>
<path d="M0 0 L4 5 L8 0 Z"/>
</svg>
</button>
{showTfMenu && (
<div className="tv-dropdown" onMouseLeave={() => setShowTfMenu(false)}>
{TIMEFRAMES.map(tf => (
<button
key={tf}
className={`tv-dropdown-item ${timeframe === tf ? 'active' : ''}`}
onClick={() => { onTimeframe(tf); setShowTfMenu(false); }}
>{tf}</button>
))}
</div>
)}
</div>
{/* Chart Type Dropdown */}
<div className="tv-dropdown-wrap">
<button
className="tv-icon-btn tv-ct-btn"
onClick={() => { setShowCtMenu(v => !v); setShowTfMenu(false); setShowIndMenu(false); }}
title="차트 유형"
>
{ChartIcon[chartType]}
</button>
{showCtMenu && (
<div className="tv-dropdown" onMouseLeave={() => setShowCtMenu(false)}>
{CHART_TYPES.map(ct => (
<button
key={ct.value}
className={`tv-dropdown-item ${chartType === ct.value ? 'active' : ''}`}
onClick={() => { onChartType(ct.value); setShowCtMenu(false); }}
>
<span style={{ marginRight: 8 }}>{ChartIcon[ct.value]}</span>
{ct.label}
</button>
))}
</div>
)}
</div>
<div className="tv-sep" />
{/* Indicators */}
<button
className={`tv-indicators-btn${showIndMenu ? ' active' : ''}`}
onClick={() => { setShowIndMenu(v => !v); setShowTfMenu(false); setShowCtMenu(false); }}
title="지표 추가 (Ctrl+I)"
>
<IcIndicator />
<span>지표</span>
{activeIndicators.length > 0 && (
<span className="tv-ind-badge">{activeIndicators.length}</span>
)}
</button>
{/* 지표 패널은 fixed overlay로 렌더링 — 부모 overflow 영향 없음 */}
{showIndMenu && (
<IndicatorDropdownPanel
activeIndicators={activeIndicators}
onAdd={type => onAddIndicator(type)}
onAddMany={onAddIndicators}
onIndicatorOrderChange={onIndicatorOrderChange}
onRemove={type => onRemoveByType(type)}
onOpenSettings={onOpenIndicatorSettings}
onClose={() => setShowIndMenu(false)}
/>
)}
{onOpenBulkIndicatorSettings && (
<button
className="tv-icon-btn"
onClick={() => {
onOpenBulkIndicatorSettings();
setShowIndMenu(false);
setShowTfMenu(false);
setShowCtMenu(false);
}}
title="보조지표 설정"
>
<IcIndBulkSettings />
</button>
)}
<div className="tv-sep" />
{/* Undo / Redo */}
<button
className={`tv-icon-btn${canUndo ? '' : ' tv-btn-disabled'}`}
onClick={canUndo ? onUndo : undefined}
title="실행 취소 (Ctrl+Z)"
style={{ opacity: canUndo ? 1 : 0.35 }}
><IcUndo /></button>
<button
className={`tv-icon-btn${canRedo ? '' : ' tv-btn-disabled'}`}
onClick={canRedo ? onRedo : undefined}
title="다시 실행 (Ctrl+Y)"
style={{ opacity: canRedo ? 1 : 0.35 }}
><IcRedo /></button>
<div className="tv-sep" />
{/* 자석모드 토글 */}
<button
className={`tv-icon-btn${magnetMode !== 'off' ? ' tv-icon-btn--active' : ''}`}
onClick={onToggleMagnet}
title={magnetMode !== 'off' ? '자석모드 끄기' : '자석모드 켜기'}
>
<IcMagnet />
</button>
{/* 전체 드로잉 삭제 */}
<button
className="tv-icon-btn"
onClick={onClearDrawings}
title="전체 드로잉 삭제"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="2" y1="2" x2="12" y2="12"/><line x1="12" y1="2" x2="2" y2="12"/>
</svg>
</button>
{/* 통계 패널 */}
<button className="tv-icon-btn" onClick={onToggleStats} title="통계 패널">
<IcStats />
</button>
{/* Auto Fibonacci */}
<button className="tv-icon-btn" onClick={onAutoFib} title="자동 피보나치">
<IcFibAuto />
</button>
{/* 9·20일 신고가·신저가 표시 */}
{onTogglePriceExtremeLabels && (
<button
className={`tv-icon-btn${priceExtremeLabelsVisible ? ' active' : ''}`}
onClick={onTogglePriceExtremeLabels}
title={priceExtremeLabelsVisible ? '9·20일 신고가·신저가 숨기기' : '9·20일 신고가·신저가 표시'}
>
<IcPriceExtreme />
</button>
)}
{/* 그리드 토글 */}
<button
className={`tv-icon-btn${showGrid ? ' active' : ''}`}
onClick={onToggleGrid}
title="그리드 표시/숨기기"
><IcGridIcon /></button>
<div className="tv-sep" />
{/* Mode toggle */}
<button
className={`tv-save-btn ${mode === 'trading' ? 'trading' : ''}`}
onClick={onMode}
title={mode === 'chart' ? '트레이딩 모드로 전환' : '차트 모드로 전환'}
>
{mode === 'trading' ? '✏ 트레이딩뷰' : '차트뷰'}
</button>
{/* 백테스팅 드롭다운 버튼 + 설정 버튼 */}
{onRunBacktest && (
<div className="bt-group">
<div className="bt-drop-wrap">
<button
ref={btBtnRef}
className={`bt-toolbar-btn${(showBtDrop || backtestActive) ? ' bt-toolbar-btn--active' : ''}`}
onClick={openBtDrop}
title="백테스팅 전략 선택"
>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 13 L1 7 L4 7 L4 13" />
<path d="M5.5 13 L5.5 4 L8.5 4 L8.5 13" />
<path d="M10 13 L10 9 L13 9 L13 13" />
<line x1="1" y1="13" x2="13" y2="13" />
</svg>
백테스팅
</button>
{showBtDrop && (
<div ref={btDropRef} className="bt-strategy-drop">
<div className="bt-drop-header">
전략 목록
<button className="bt-drop-refresh" onClick={async () => {
setBtLoading(true);
try { setBtStrategies(await loadBtStrategies()); }
finally { setBtLoading(false); }
}}></button>
</div>
{btLoading ? (
<div className="bt-drop-empty">로딩 중…</div>
) : btStrategies.length === 0 ? (
<div className="bt-drop-empty">저장된 전략이 없습니다<br/><span style={{fontSize:'10px',opacity:.6}}>투자전략 화면에서 전략을 추가하세요</span></div>
) : (
<ul className="bt-drop-list">
{btStrategies.map((s, i) => {
const runId = s.id ?? `local-${i}`;
const isRunning = backtestRunning && backtestRunningId === runId;
const isDbId = s.id !== undefined && s.id > 0 && s.id < 1_000_000_000;
return (
<li key={i} className="bt-drop-item">
<span className="bt-drop-name">
{s.name}
</span>
<button
className={`bt-drop-run${isRunning ? ' running' : ''}`}
disabled={backtestRunning}
onClick={() => {
if (isDbId && s.id) {
onRunBacktest({ strategyId: s.id, strategyName: s.name } as Parameters<typeof onRunBacktest>[0]);
} else {
onRunBacktest({ buyCondition: s.buyCondition, sellCondition: s.sellCondition, strategyName: s.name } as Parameters<typeof onRunBacktest>[0]);
}
setShowBtDrop(false);
}}
>
{isRunning ? '실행 중…' : '▶ 실행'}
</button>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
{/* 백테스팅 설정 버튼 */}
{onOpenBtSettings && (
<button
className="bt-settings-btn"
onClick={onOpenBtSettings}
title="백테스팅 설정"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="8" cy="8" r="2.5"/>
<path d="M8 1.5 L8 3.5 M8 12.5 L8 14.5 M1.5 8 L3.5 8 M12.5 8 L14.5 8
M3.4 3.4 L4.8 4.8 M11.2 11.2 L12.6 12.6
M12.6 3.4 L11.2 4.8 M4.8 11.2 L3.4 12.6" strokeLinecap="round"/>
</svg>
</button>
)}
{/* 결과보기 버튼 — 설정 버튼 우측 */}
{onOpenBtResults && (
<button
className={`bt-settings-btn${hasBtResult ? ' bt-settings-btn--has-result' : ''}`}
onClick={onOpenBtResults}
title="백테스팅 결과 보기"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/>
<rect x="6.5" y="5.5" width="3" height="9" rx="0.5"/>
<rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/>
<polyline points="3,9 8,5.5 13,2.5" strokeLinecap="round"/>
<circle cx="3" cy="9" r="1" fill="currentColor" stroke="none"/>
<circle cx="8" cy="5.5" r="1" fill="currentColor" stroke="none"/>
<circle cx="13" cy="2.5" r="1" fill="currentColor" stroke="none"/>
</svg>
</button>
)}
{/* 매수/매도 마커 삭제 버튼 — 결과보기 버튼 우측, 마커가 있을 때만 표시 */}
{onClearBtMarkers && hasBtResult && (
<button
className="bt-settings-btn bt-settings-btn--clear"
onClick={onClearBtMarkers}
title="매수/매도 마커 삭제"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,10 4,5 7,2 10,5 10,10"/>
<polyline points="6,10 6,13 8,15 10,13 10,10"/>
<line x1="12" y1="1" x2="15" y2="4"/>
<line x1="15" y1="1" x2="12" y2="4"/>
</svg>
</button>
)}
{/* 실시간 전략 체크 패널 토글 버튼 */}
{onToggleLiveStrategy && (
<button
className={`bt-settings-btn${liveStrategyActive ? ' bt-settings-btn--live-on' : ''}`}
onClick={onToggleLiveStrategy}
title={liveStrategyActive ? '실시간 전략 체크 패널 닫기' : '실시간 전략 체크 설정'}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="6"/>
<circle cx="8" cy="8" r="2.5"/>
<line x1="8" y1="1.5" x2="8" y2="4"/>
<line x1="8" y1="12" x2="8" y2="14.5"/>
<line x1="1.5" y1="8" x2="4" y2="8"/>
<line x1="12" y1="8" x2="14.5" y2="8"/>
</svg>
</button>
)}
</div>
)}
</div>
{/* ── Right ────────────────────────────────────────────────────────── */}
<div className="tv-toolbar-right">
{/* 마켓 패널 토글 버튼 */}
{onToggleMarketPanel && (
<button
className={`tv-icon-btn${marketPanelActive ? ' active' : ''}`}
onClick={onToggleMarketPanel}
title={marketPanelActive ? '마켓 패널 닫기' : '마켓 패널 열기'}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4">
<rect x="1" y="1" width="13" height="13" rx="1.5"/>
<line x1="5.5" y1="1" x2="5.5" y2="14"/>
<line x1="2.5" y1="5" x2="4.5" y2="5"/>
<line x1="2.5" y1="7.5" x2="4.5" y2="7.5"/>
<line x1="2.5" y1="10" x2="4.5" y2="10"/>
</svg>
</button>
)}
{/* 돋보기(확대경) 버튼 — 레이아웃 버튼 좌측 */}
{onToggleMagnifier && (
<button
className={`tv-icon-btn${magnifierActive ? ' active' : ''}`}
onClick={onToggleMagnifier}
title={magnifierActive ? '확대경 끄기' : '확대경 켜기'}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="6.5" cy="6.5" r="4.5"/>
<line x1="10" y1="10" x2="13.5" y2="13.5"/>
<line x1="6.5" y1="4" x2="6.5" y2="9"/>
<line x1="4" y1="6.5" x2="9" y2="6.5"/>
</svg>
</button>
)}
{/* 레이아웃 선택 버튼 */}
<button
ref={layoutBtnRef}
className={`tv-icon-btn lp-trigger-btn${showLayout ? ' active' : ''}`}
onClick={handleLayoutBtnClick}
title="레이아웃 설정"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3">
<rect x="1" y="1" width="14" height="14" rx="1.5"/>
{layoutId === '1' && <rect x="2.5" y="2.5" width="11" height="11" rx="0.5" fill="currentColor" fillOpacity="0.2"/>}
{layoutId === '2v' && <><rect x="2.5" y="2.5" width="4.5" height="11" rx="0.5" fill="currentColor" fillOpacity="0.2"/><rect x="9" y="2.5" width="4.5" height="11" rx="0.5" fill="currentColor" fillOpacity="0.2"/></>}
{layoutId === '2h' && <><rect x="2.5" y="2.5" width="11" height="4.5" rx="0.5" fill="currentColor" fillOpacity="0.2"/><rect x="2.5" y="9" width="11" height="4.5" rx="0.5" fill="currentColor" fillOpacity="0.2"/></>}
{!['1','2v','2h'].includes(layoutId) && (
<>
<rect x="2.5" y="2.5" width="4.5" height="4.5" rx="0.5" fill="currentColor" fillOpacity="0.2"/>
<rect x="9" y="2.5" width="4.5" height="4.5" rx="0.5" fill="currentColor" fillOpacity="0.2"/>
<rect x="2.5" y="9" width="4.5" height="4.5" rx="0.5" fill="currentColor" fillOpacity="0.2"/>
<rect x="9" y="9" width="4.5" height="4.5" rx="0.5" fill="currentColor" fillOpacity="0.2"/>
</>
)}
</svg>
</button>
{/* Portal: 레이아웃 팝업을 body에 fixed 렌더 (toolbar overflow 클리핑 우회) */}
{showLayout && layoutPopupPos && onLayoutSelect && onSyncChange &&
ReactDOM.createPortal(
<div style={{
position: 'fixed',
top: layoutPopupPos.top,
right: layoutPopupPos.right,
zIndex: 99999,
}}>
<LayoutPicker
currentId={layoutId}
syncOptions={syncOptions}
onSelect={def => {
onLayoutSelect(def);
setShowLayout(false);
setLayoutPopupPos(null);
}}
onSyncChange={onSyncChange}
onClose={() => { setShowLayout(false); setLayoutPopupPos(null); }}
/>
</div>,
document.body,
)
}
<button className="tv-icon-btn" onClick={onFitContent} title="화면 맞춤 (F)">
<IcReplay />
</button>
{onScrollToNow && (
<button className="tv-icon-btn tv-realtime-btn" onClick={onScrollToNow} title="현재 시각으로 이동">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="13 17 18 12 13 7"/>
<polyline points="6 17 11 12 6 7"/>
</svg>
</button>
)}
{onOpenTradeNotifications && (
<>
{(onTradeAlertPopupPosition || onTradeAlertPopupLayout) && (
<TradeAlertPopupMenubarSelect
position={tradeAlertPopupPosition}
layout={tradeAlertPopupLayout}
gridCols={tradeAlertPopupGridCols}
onPositionChange={onTradeAlertPopupPosition}
onLayoutChange={onTradeAlertPopupLayout}
onGridColsChange={onTradeAlertPopupGridCols}
/>
)}
<span className="tv-trade-notify-wrap">
<button
className="tv-icon-btn"
onClick={onOpenTradeNotifications}
title={`매매 시그널 알림${tradeNotifyUnread > 0 ? ` (미확인 ${tradeNotifyUnread})` : ''}`}
>
<IcTradeSignal />
</button>
{tradeNotifyUnread > 0 && (
<span className="tv-trade-notify-badge">
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
</span>
)}
</span>
</>
)}
<button className="tv-icon-btn" onClick={onToggleAlert} title="가격 알림">
<IcBell />
</button>
<button className="tv-icon-btn" onClick={onScreenshot} title="스크린샷 (Alt+S)">
<IcCamera />
</button>
</div>
</div>
</>
);
};
export default Toolbar;