import React, { useRef, useEffect, useState, useCallback } from 'react'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import ReactDOM from 'react-dom'; import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry'; import { MAIN_INDICATOR_TYPES, MAIN_INDICATOR_LABELS, customTabKey, isCustomTabKey, parseCustomTabKey, type IndicatorTabKey, } from '../utils/indicatorMainTab'; import { confirmAndApplyIndicatorTab, } from '../utils/indicatorTabApply'; import { getOrderedMainIndicatorTypes, saveMainTabOrder, } from '../utils/indicatorMainTabOrder'; import { mergeIndicatorListOrder, reorderIndicatorListType, saveIndicatorListOrder, } from '../utils/indicatorListOrder'; import { getSettingsIndicatorTypes } from '../utils/indicatorSettingsList'; import { loadCustomTabs, createCustomTab, updateCustomTab, deleteCustomTab, type IndicatorCustomTab, } from '../utils/indicatorCustomTabsStorage'; import IndicatorCustomTabEditor from './IndicatorCustomTabEditor'; 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'; interface BtStrategy { id?: number; name: string; source: 'db'; buyCondition?: unknown; sellCondition?: unknown; } function loadBtStrategies(): Promise { 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; /** 모바일 앱 다운로드 */ onOpenAppDownload?: () => 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 IcAppDownload = () => ( ); /** 매매 시그널 알림 (번개 아이콘으로 가격 알림 벨과 구분) */ const IcTradeSignal = () => ( ); const IcGridIcon = () => ( ); const IcStats = () => ( ); const IcFibAuto = () => ( ); const IcMagnet = () => ( ); const IcList = () => ( ); const IcReplay = () => ( ); // ─── 기본 탭 (사용자 정의 탭은 localStorage) ─────────────────────────────── const BUILTIN_TABS: Array<{ key: IndicatorTabKey; label: string }> = [ { key: 'All', label: '전체' }, { key: 'Main', label: '주요지표' }, ]; // ─── 차트 타입 아이콘 매핑 ───────────────────────────────────────────────── const ChartIcon: Record = { candlestick: , bar: , line: , area: , baseline: , }; 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: '기준선' }, ]; // ─── 지표 선택 오버레이 패널 ────────────────────────────────────────────── interface IndDropdownProps { activeIndicators: IndicatorConfig[]; onAdd: (type: string) => void; onAddMany?: (types: string[]) => void; onIndicatorOrderChange?: (orderedTypes: string[]) => void; onRemove: (type: string) => void; onOpenSettings?: (type: string) => void; onClose: () => void; } const IndDropdown: React.FC = ({ activeIndicators, onAdd, onAddMany, onIndicatorOrderChange, onRemove, onOpenSettings, onClose, }) => { const [search, setSearch] = React.useState(''); const [category, setCategory] = React.useState('All'); const [customTabs, setCustomTabs] = React.useState(() => loadCustomTabs()); const [mainOrderTick, setMainOrderTick] = React.useState(0); const [draggingType, setDraggingType] = React.useState(null); const [dragOverType, setDragOverType] = React.useState(null); const [editorOpen, setEditorOpen] = React.useState(false); const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create'); const searchRef = useRef(null); const searchFocusedRef = useRef(false); const refreshCustomTabs = useCallback(() => { setCustomTabs(loadCustomTabs()); }, []); const selectedCustomTab = React.useMemo(() => { const id = parseCustomTabKey(category); return id ? customTabs.find(t => t.id === id) ?? null : null; }, [category, customTabs]); const openCreateEditor = () => { setEditorMode('create'); setEditorOpen(true); }; const openEditEditor = () => { if (!selectedCustomTab) return; setEditorMode('edit'); setEditorOpen(true); }; const handleDeleteTab = () => { if (!selectedCustomTab) return; if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return; deleteCustomTab(selectedCustomTab.id); refreshCustomTabs(); setCategory('All'); }; const handleEditorSave = (name: string, indicatorTypes: string[]) => { if (editorMode === 'create') { const tab = createCustomTab(name, indicatorTypes); refreshCustomTabs(); setCategory(customTabKey(tab.id)); } else if (selectedCustomTab) { updateCustomTab(selectedCustomTab.id, { name, indicatorTypes }); refreshCustomTabs(); } setEditorOpen(false); setSearch(''); }; const tabTypeOrder = React.useMemo((): string[] | null => { void mainOrderTick; if (search.trim()) return null; if (category === 'Main') return getOrderedMainIndicatorTypes(); if (isCustomTabKey(category) && selectedCustomTab) { return [...selectedCustomTab.indicatorTypes]; } return null; }, [category, selectedCustomTab, customTabs, search, mainOrderTick]); const canReorderList = tabTypeOrder != null && tabTypeOrder.length > 1; const syncChartToTabOrder = useCallback(() => { if (tabTypeOrder?.length && onIndicatorOrderChange) { onIndicatorOrderChange(tabTypeOrder); } }, [tabTypeOrder, onIndicatorOrderChange]); const moveTypeInTabList = useCallback((fromType: string, toType: string) => { if (!tabTypeOrder) return; const next = reorderIndicatorListType(tabTypeOrder, fromType, toType, 'before'); if (category === 'Main') { saveMainTabOrder(next); setMainOrderTick(t => t + 1); saveIndicatorListOrder(mergeIndicatorListOrder(next, getSettingsIndicatorTypes())); onIndicatorOrderChange?.(next); } else if (isCustomTabKey(category) && selectedCustomTab) { updateCustomTab(selectedCustomTab.id, { indicatorTypes: next }); refreshCustomTabs(); onIndicatorOrderChange?.(next); } }, [tabTypeOrder, category, selectedCustomTab, refreshCustomTabs, onIndicatorOrderChange]); useEffect(() => { const clearDrag = () => { setDraggingType(null); setDragOverType(null); }; document.addEventListener('dragend', clearDrag); return () => document.removeEventListener('dragend', clearDrag); }, []); const addAllContext = React.useMemo(() => { if (category === 'All') return null; if (category === 'Main') { return { label: '주요지표', types: getOrderedMainIndicatorTypes() }; } if (isCustomTabKey(category) && selectedCustomTab) { const types = selectedCustomTab.indicatorTypes.filter( t => INDICATOR_REGISTRY.some(d => d.type === t), ); return { label: selectedCustomTab.name, types }; } return null; }, [category, selectedCustomTab]); const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0; const handleAddAllInTab = () => { if (!addAllContext || addAllContext.types.length === 0) return; if (category === 'Main') { confirmAndApplyIndicatorTab({ kind: 'main' }, onAddMany, onAdd); return; } if (isCustomTabKey(category) && selectedCustomTab) { confirmAndApplyIndicatorTab({ kind: 'custom', tab: selectedCustomTab }, onAddMany, onAdd); } }; const stopHeaderBtn = (e: React.MouseEvent) => { e.stopPropagation(); }; useEffect(() => { if (editorOpen || searchFocusedRef.current) return; searchRef.current?.focus(); searchFocusedRef.current = true; }, [editorOpen]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; if (editorOpen) { setEditorOpen(false); return; } onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, editorOpen]); 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' && tabTypeOrder) { return tabTypeOrder .map(type => INDICATOR_REGISTRY.find(d => d.type === type)) .filter((d): d is IndicatorDef => d != null) .filter(matchesQuery); } if (isCustomTabKey(category) && tabTypeOrder) { return tabTypeOrder .map(type => INDICATOR_REGISTRY.find(d => d.type === type)) .filter((d): d is IndicatorDef => d != null) .filter(matchesQuery); } return INDICATOR_REGISTRY.filter(matchesQuery); }, [search, category, customTabs, tabTypeOrder]); const { panelRef, dragging, onHeaderPointerDown, headerTouchStyle, panelStyle, headerCursor, } = useDraggablePanel({ centerOnMount: true }); return ( // 배경 클릭 시 닫힘
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()} >
지표 추가
{/* 검색창 */}
{ setSearch(e.target.value); setCategory('All'); }} /> {search && ( )}
{/* 카테고리 탭 */}
{BUILTIN_TABS.map(c => { const cnt = c.key === 'All' ? INDICATOR_REGISTRY.length : MAIN_INDICATOR_TYPES.length; return ( ); })} {customTabs.map(tab => ( ))}
{/* 지표 목록 */}
{/* 활성 지표가 있을 때 상단에 표시 */} {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 ? `"${search}" 검색 결과 없음` : isCustomTabKey(category) ? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.' : '표시할 지표가 없습니다'}
) : ( filtered.map(def => { const active = isActive(def.type); const mainLbl = (category === 'Main' || isCustomTabKey(category)) ? MAIN_INDICATOR_LABELS[def.type] : undefined; const isDragOver = canReorderList && dragOverType === def.type && draggingType !== def.type; return (
{ if (!draggingType || draggingType === def.type) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverType(def.type); } : undefined} onDragLeave={canReorderList ? () => { if (dragOverType === def.type) setDragOverType(null); } : undefined} onDrop={canReorderList ? e => { e.preventDefault(); const from = e.dataTransfer.getData('text/indicator-type') || draggingType; if (from && from !== def.type) moveTypeInTabList(from, def.type); setDraggingType(null); setDragOverType(null); } : undefined} > {canReorderList && ( )}
{mainLbl?.ko ?? def.description} {mainLbl?.en ?? def.name}
{def.overlay && 오버레이} {active ? ( ) : ( )} {onOpenSettings && ( )}
); }) )}
{editorOpen && ( setEditorOpen(false)} /> )}
); }; // ─── Toolbar ────────────────────────────────────────────────────────────── const Toolbar: React.FC = ({ 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, onOpenAppDownload, 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)} onAddMany={onAddIndicators} onIndicatorOrderChange={onIndicatorOrderChange} 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}
  • ); })}
)}
)}
{/* 백테스팅 설정 버튼 */} {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 && ( )} {onOpenAppDownload && ( )} {onOpenTradeNotifications && ( {tradeNotifyUnread > 0 && ( {tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread} )} )}
); }; export default Toolbar;