import React, { useRef, useEffect, useState, useCallback } from 'react'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import ReactDOM from 'react-dom'; import { INDICATOR_REGISTRY, type IndicatorDef, type IndicatorCategory } from '../utils/indicatorRegistry'; import { MAIN_INDICATOR_TYPES, MAIN_INDICATOR_LABELS, type IndicatorTabKey, } from '../utils/indicatorMainTab'; import { getIndicatorListLabels } from '../utils/indicatorSettingsList'; // IndicatorCategory는 IndDropdown 상태 타입에 사용됨 import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types'; import { MarketSearchPanel } from './MarketSearchPanel'; import { getKoreanName } from '../utils/marketNameCache'; import LayoutPicker from './LayoutPicker'; import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes'; import { loadStrategies } from '../utils/backendApi'; const LOCAL_BT_KEY = 'gc_strat_v2'; interface BtStrategy { id?: number; name: string; source: 'db' | 'local'; buyCondition?: unknown; sellCondition?: unknown; } function loadBtStrategies(): Promise { return loadStrategies() .then(list => { if (list && list.length > 0) { return list.map(s => ({ id: s.id, name: s.name, source: 'db' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition })); } // localStorage 폴백 try { const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] = JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]'); return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition })); } catch { return []; } }) .catch(() => { try { const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] = JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]'); return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition })); } catch { return []; } }); } 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; 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; onFullscreen: () => 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; /** 백테스팅 드롭다운 표시 여부 */ 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; } // ─── SVG 아이콘 ──────────────────────────────────────────────────────────── const IcSearch = () => ( ); const IcPlus = () => ( ); const IcGear = () => ( ); const IcCandlestick = () => ( ); const IcLine = () => ( ); const IcArea = () => ( ); const IcBar = () => ( ); const IcIndicator = () => ( ); /** 보조지표 일괄 설정 (막대 + 슬라이더) */ const IcIndBulkSettings = () => ( ); const IcUndo = () => ( ); const IcRedo = () => ( ); const IcCamera = () => ( ); const IcFullscreen = () => ( ); const IcBell = () => ( ); /** 매매 시그널 알림 (번개 아이콘으로 가격 알림 벨과 구분) */ const IcTradeSignal = () => ( ); const IcGridIcon = () => ( ); const IcStats = () => ( ); const IcFibAuto = () => ( ); const IcMagnet = () => ( ); const IcList = () => ( ); const IcReplay = () => ( ); // ─── 카테고리 목록 ───────────────────────────────────────────────────────── const CATEGORIES: Array<{ key: IndicatorTabKey; label: string }> = [ { key: 'All', label: '전체' }, { key: 'Main', label: 'Main' }, { key: 'Moving Averages', label: 'MA' }, { key: 'Channels & Bands', label: '밴드' }, { key: 'Oscillators', label: '오실레이터' }, { key: 'Momentum', label: '모멘텀' }, { key: 'Trend', label: '추세' }, { key: 'Volatility', label: '변동성' }, { key: 'Volume', label: '거래량' }, { key: 'Candlestick Patterns',label: '패턴' }, ]; // ─── 차트 타입 아이콘 매핑 ───────────────────────────────────────────────── const ChartIcon: Record = { candlestick: , bar: , line: , area: , baseline: , }; const TIMEFRAMES: Timeframe[] = ['1m','3m','5m','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: '기준선' }, ]; // ─── 지표 선택 오버레이 패널 ────────────────────────────────────────────── interface IndDropdownProps { activeIndicators: IndicatorConfig[]; onAdd: (type: string) => void; onRemove: (type: string) => void; onOpenSettings?: (type: string) => void; onClose: () => void; } const IndDropdown: React.FC = ({ activeIndicators, onAdd, onRemove, onOpenSettings, onClose, }) => { const [search, setSearch] = React.useState(''); const [category, setCategory] = React.useState('All'); const searchRef = useRef(null); useEffect(() => { searchRef.current?.focus(); // ESC 키로 닫기 const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); const isActive = (type: string) => activeIndicators.some(a => a.type === type); const filtered = React.useMemo(() => { const q = search.toLowerCase(); const matchesQuery = (d: IndicatorDef) => { if (!q) return true; const main = MAIN_INDICATOR_LABELS[d.type]; return ( d.name.toLowerCase().includes(q) || d.shortName.toLowerCase().includes(q) || d.description.includes(q) || (main?.ko.includes(q) ?? false) || (main?.en.toLowerCase().includes(q) ?? false) ); }; if (category === 'Main') { return MAIN_INDICATOR_TYPES .map(type => INDICATOR_REGISTRY.find(d => d.type === type)) .filter((d): d is IndicatorDef => d != null) .filter(matchesQuery); } return INDICATOR_REGISTRY.filter(d => { const matchCat = category === 'All' || d.category === category; return matchCat && matchesQuery(d); }); }, [search, category]); const { panelRef, dragging, onHeaderPointerDown, headerTouchStyle, panelStyle, headerCursor, } = useDraggablePanel({ centerOnMount: true, initialPosition: { x: 0, y: 56 } }); return ( // 배경 클릭 시 닫힘
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()} >
지표 추가
{/* 검색창 */}
{ setSearch(e.target.value); setCategory('All'); }} /> {search && ( )}
{/* 카테고리 탭 */}
{CATEGORIES.map(c => { const cnt = c.key === 'All' ? INDICATOR_REGISTRY.length : c.key === 'Main' ? MAIN_INDICATOR_TYPES.length : INDICATOR_REGISTRY.filter(d => d.category === c.key).length; return ( ); })}
{/* 지표 목록 */}
{/* 활성 지표가 있을 때 상단에 표시 */} {activeIndicators.length > 0 && !search && category === 'All' && (
활성 지표 ({activeIndicators.length})
{activeIndicators.map(a => { const lbl = getIndicatorListLabels(a.type); return (
{lbl.ko} {lbl.en}
{onOpenSettings && ( )}
); })}
)} {filtered.length === 0 ? (
"{search}" 검색 결과 없음
) : ( filtered.map(def => { const active = isActive(def.type); const mainLbl = category === 'Main' ? MAIN_INDICATOR_LABELS[def.type] : undefined; return (
{mainLbl?.ko ?? def.description} {mainLbl?.en ?? def.name}
{def.overlay && 오버레이} {active ? ( ) : ( )} {onOpenSettings && ( )}
); }) )}
); }; // ─── Toolbar ────────────────────────────────────────────────────────────── const Toolbar: React.FC = ({ symbol, timeframe, chartType, theme, mode, logScale, showGrid, activeIndicators, onSymbol, onTimeframe, onChartType, onTheme, onMode, onFitContent, onScrollToNow, onAddIndicator, onRemoveByType, onAutoFib, onClearFib, onScreenshot, onToggleStats, onToggleWatch, onToggleAlert, tradeNotifyUnread = 0, onOpenTradeNotifications, magnetMode = 'off', onToggleMagnet, onFullscreen, onUndo, onRedo, canUndo, canRedo, onClearDrawings, onToggleGrid, layoutId = '1', syncOptions = DEFAULT_SYNC, onLayoutSelect, onSyncChange, magnifierActive = false, onToggleMagnifier, marketPanelActive = false, onToggleMarketPanel, backtestActive = false, onRunBacktest, backtestRunning = false, backtestRunningId, onOpenBtSettings, onOpenBtResults, hasBtResult = false, onClearBtMarkers, onToggleLiveStrategy, liveStrategyActive = false, onOpenBulkIndicatorSettings, onOpenIndicatorSettings, }) => { 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(null); // 백테스팅 드롭다운 const [showBtDrop, setShowBtDrop] = useState(false); const [btStrategies, setBtStrategies] = useState([]); const [btLoading, setBtLoading] = useState(false); const btBtnRef = useRef(null); const btDropRef = useRef(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]); return ( <> {/* 종목 검색 패널 */} {showMarket && ( { onSymbol(s); setShowMarket(false); }} onClose={() => setShowMarket(false)} /> )}
{/* ── Left ─────────────────────────────────────────────────────────── */}
{/* Symbol Search → 클릭 시 MarketSearchPanel 열기 */}
{/* Compare */}
{/* Timeframe Dropdown */}
{showTfMenu && (
setShowTfMenu(false)}> {TIMEFRAMES.map(tf => ( ))}
)}
{/* Chart Type Dropdown */}
{showCtMenu && (
setShowCtMenu(false)}> {CHART_TYPES.map(ct => ( ))}
)}
{/* Indicators */} {/* 지표 패널은 fixed overlay로 렌더링 — 부모 overflow 영향 없음 */} {showIndMenu && ( onAddIndicator(type)} onRemove={type => onRemoveByType(type)} onOpenSettings={onOpenIndicatorSettings} onClose={() => setShowIndMenu(false)} /> )} {onOpenBulkIndicatorSettings && ( )}
{/* Undo / Redo */}
{/* 자석모드 토글 */} {/* 전체 드로잉 삭제 */} {/* 통계 패널 */} {/* Auto Fibonacci */} {/* 그리드 토글 */}
{/* Mode toggle */} {/* 백테스팅 드롭다운 버튼 + 설정 버튼 */} {onRunBacktest && (
{showBtDrop && (
전략 목록
{btLoading ? (
로딩 중…
) : btStrategies.length === 0 ? (
저장된 전략이 없습니다
투자전략 화면에서 전략을 추가하세요
) : (
    {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 (
  • {s.name} {s.source === 'local' && 로컬}
  • ); })}
)}
)}
{/* 백테스팅 설정 버튼 */} {onOpenBtSettings && ( )} {/* 결과보기 버튼 — 설정 버튼 우측 */} {onOpenBtResults && ( )} {/* 매수/매도 마커 삭제 버튼 — 결과보기 버튼 우측, 마커가 있을 때만 표시 */} {onClearBtMarkers && hasBtResult && ( )} {/* 실시간 전략 체크 패널 토글 버튼 */} {onToggleLiveStrategy && ( )}
)}
{/* ── Right ────────────────────────────────────────────────────────── */}
{/* 마켓 패널 토글 버튼 */} {onToggleMarketPanel && ( )} {/* 돋보기(확대경) 버튼 — 레이아웃 버튼 좌측 */} {onToggleMagnifier && ( )} {/* 레이아웃 선택 버튼 */} {/* Portal: 레이아웃 팝업을 body에 fixed 렌더 (toolbar overflow 클리핑 우회) */} {showLayout && layoutPopupPos && onLayoutSelect && onSyncChange && ReactDOM.createPortal(
{ onLayoutSelect(def); setShowLayout(false); setLayoutPopupPos(null); }} onSyncChange={onSyncChange} onClose={() => { setShowLayout(false); setLayoutPopupPos(null); }} />
, document.body, ) } {onScrollToNow && ( )} {onOpenTradeNotifications && ( {tradeNotifyUnread > 0 && ( {tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread} )} )}
); }; export default Toolbar;