1319 lines
54 KiB
TypeScript
1319 lines
54 KiB
TypeScript
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';
|
|
|
|
const LOCAL_BT_KEY = 'gc_strat_v2';
|
|
|
|
interface BtStrategy { id?: number; name: string; source: 'db' | 'local'; buyCondition?: unknown; sellCondition?: unknown; }
|
|
|
|
function loadBtStrategies(): Promise<BtStrategy[]> {
|
|
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;
|
|
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;
|
|
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 = () => (
|
|
<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 IcGear = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
|
<circle cx="7" cy="7" r="2.3"/>
|
|
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
|
|
</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 IcFullscreen = () => (
|
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<polyline points="1,5 1,1 5,1"/><polyline points="10,1 14,1 14,5"/>
|
|
<polyline points="14,10 14,14 10,14"/><polyline points="5,14 1,14 1,10"/>
|
|
</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 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>
|
|
);
|
|
|
|
// ─── 기본 탭 (사용자 정의 탭은 localStorage) ───────────────────────────────
|
|
const BUILTIN_TABS: Array<{ key: IndicatorTabKey; label: string }> = [
|
|
{ key: 'All', label: '전체' },
|
|
{ key: 'Main', label: '주요지표' },
|
|
];
|
|
|
|
// ─── 차트 타입 아이콘 매핑 ─────────────────────────────────────────────────
|
|
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: '기준선' },
|
|
];
|
|
|
|
// ─── 지표 선택 오버레이 패널 ──────────────────────────────────────────────
|
|
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<IndDropdownProps> = ({
|
|
activeIndicators, onAdd, onAddMany, onIndicatorOrderChange, onRemove, onOpenSettings, onClose,
|
|
}) => {
|
|
const [search, setSearch] = React.useState('');
|
|
const [category, setCategory] = React.useState<IndicatorTabKey>('All');
|
|
const [customTabs, setCustomTabs] = React.useState<IndicatorCustomTab[]>(() => loadCustomTabs());
|
|
const [mainOrderTick, setMainOrderTick] = React.useState(0);
|
|
const [draggingType, setDraggingType] = React.useState<string | null>(null);
|
|
const [dragOverType, setDragOverType] = React.useState<string | null>(null);
|
|
const [editorOpen, setEditorOpen] = React.useState(false);
|
|
const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create');
|
|
const searchRef = useRef<HTMLInputElement>(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, initialPosition: { x: 0, y: 56 } });
|
|
|
|
return (
|
|
// 배경 클릭 시 닫힘
|
|
<div className="ind-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
|
<div
|
|
ref={panelRef}
|
|
className="ind-panel"
|
|
style={{
|
|
...panelStyle,
|
|
zIndex: 2001,
|
|
cursor: dragging ? 'grabbing' : undefined,
|
|
}}
|
|
onMouseDown={e => e.stopPropagation()}
|
|
>
|
|
<div
|
|
className="gc-popup-header"
|
|
onPointerDown={onHeaderPointerDown}
|
|
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
|
>
|
|
<span className="gc-popup-title">지표 추가</span>
|
|
<div className="ind-panel-header-actions">
|
|
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
|
<button
|
|
type="button"
|
|
className="ind-panel-header-btn"
|
|
onPointerDown={stopHeaderBtn}
|
|
onClick={openCreateEditor}
|
|
>
|
|
탭 추가
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ind-panel-header-btn"
|
|
disabled={!selectedCustomTab}
|
|
onPointerDown={stopHeaderBtn}
|
|
onClick={openEditEditor}
|
|
>
|
|
수정
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ind-panel-header-btn danger"
|
|
disabled={!selectedCustomTab}
|
|
onPointerDown={stopHeaderBtn}
|
|
onClick={handleDeleteTab}
|
|
>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색창 */}
|
|
<div className="ind-panel-search-row">
|
|
<div className="ind-panel-search-wrap">
|
|
<IcSearch />
|
|
<input
|
|
ref={searchRef}
|
|
className="ind-panel-search"
|
|
placeholder={`지표 검색... (전체 ${INDICATOR_REGISTRY.length}개)`}
|
|
value={search}
|
|
onChange={e => { setSearch(e.target.value); setCategory('All'); }}
|
|
/>
|
|
{search && (
|
|
<button className="ind-panel-clear" onClick={() => setSearch('')}>✕</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 카테고리 탭 */}
|
|
<div className="ind-panel-cats-row">
|
|
<div className="ind-panel-cats" role="tablist" aria-label="지표 탭">
|
|
{BUILTIN_TABS.map(c => {
|
|
const cnt = c.key === 'All'
|
|
? INDICATOR_REGISTRY.length
|
|
: MAIN_INDICATOR_TYPES.length;
|
|
return (
|
|
<button
|
|
key={c.key}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={category === c.key}
|
|
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
|
onClick={() => { setCategory(c.key); setSearch(''); }}
|
|
>
|
|
{c.label}
|
|
<span className="ind-panel-cat-cnt"> {cnt}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{customTabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={category === customTabKey(tab.id)}
|
|
className={`ind-panel-cat${category === customTabKey(tab.id) ? ' active' : ''}`}
|
|
onClick={() => { setCategory(customTabKey(tab.id)); setSearch(''); }}
|
|
>
|
|
{tab.name}
|
|
<span className="ind-panel-cat-cnt"> {tab.indicatorTypes.length}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="ind-panel-add-all"
|
|
disabled={addAllDisabled}
|
|
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '기존 보조지표를 제거하고 현재 탭 지표로 교체'}
|
|
aria-label="현재 탭 지표로 보조지표 교체"
|
|
onClick={handleAddAllInTab}
|
|
>
|
|
<IcPlus />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 지표 목록 */}
|
|
<div className="ind-panel-list">
|
|
{/* 활성 지표가 있을 때 상단에 표시 */}
|
|
{activeIndicators.length > 0 && !search && category === 'All' && (
|
|
<div className="ind-panel-section">
|
|
<div className="ind-panel-section-title">활성 지표 ({activeIndicators.length})</div>
|
|
{activeIndicators.map(a => {
|
|
const lbl = getIndicatorListLabels(a.type);
|
|
return (
|
|
<div key={a.id} className="ind-panel-item active">
|
|
<div className="ind-panel-item-info">
|
|
<span className="ind-panel-name">{lbl.ko}</span>
|
|
<span className="ind-panel-desc">{lbl.en}</span>
|
|
</div>
|
|
<div className="ind-panel-item-right">
|
|
<button className="ind-panel-rm" onClick={() => onRemove(a.type)}>제거</button>
|
|
{onOpenSettings && (
|
|
<button
|
|
type="button"
|
|
className="ind-panel-settings"
|
|
title="설정"
|
|
aria-label={`${lbl.ko} 설정`}
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onOpenSettings(a.type);
|
|
onClose();
|
|
}}
|
|
>
|
|
<IcGear />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="ind-panel-divider" />
|
|
</div>
|
|
)}
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="ind-panel-empty">
|
|
{search
|
|
? `"${search}" 검색 결과 없음`
|
|
: isCustomTabKey(category)
|
|
? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.'
|
|
: '표시할 지표가 없습니다'}
|
|
</div>
|
|
) : (
|
|
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 (
|
|
<div
|
|
key={def.type}
|
|
className={`ind-panel-item${active ? ' active' : ''}${isDragOver ? ' ind-panel-item--drag-over' : ''}`}
|
|
onDragOver={canReorderList ? e => {
|
|
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 && (
|
|
<button
|
|
type="button"
|
|
className="ind-settings-drag-handle ind-panel-drag-handle"
|
|
draggable
|
|
title="드래그하여 순서 변경"
|
|
aria-label="순서 변경"
|
|
onDragStart={e => {
|
|
setDraggingType(def.type);
|
|
e.dataTransfer.setData('text/indicator-type', def.type);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
⠿
|
|
</button>
|
|
)}
|
|
<div className="ind-panel-item-info">
|
|
<span className="ind-panel-name">{mainLbl?.ko ?? def.description}</span>
|
|
<span className="ind-panel-desc">{mainLbl?.en ?? def.name}</span>
|
|
</div>
|
|
<div className="ind-panel-item-right">
|
|
{def.overlay && <span className="ind-panel-badge">오버레이</span>}
|
|
{active ? (
|
|
<button className="ind-panel-rm" onClick={() => onRemove(def.type)}>제거</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="ind-panel-add"
|
|
title="차트에 추가"
|
|
onClick={() => {
|
|
onAdd(def.type);
|
|
syncChartToTabOrder();
|
|
}}
|
|
>
|
|
<IcPlus />
|
|
</button>
|
|
)}
|
|
{onOpenSettings && (
|
|
<button
|
|
type="button"
|
|
className="ind-panel-settings"
|
|
title="설정"
|
|
aria-label={`${mainLbl?.ko ?? def.koreanName} 설정`}
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onOpenSettings(def.type);
|
|
onClose();
|
|
}}
|
|
>
|
|
<IcGear />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{editorOpen && (
|
|
<IndicatorCustomTabEditor
|
|
mode={editorMode}
|
|
initialName={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.name : ''}
|
|
initialTypes={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.indicatorTypes : []}
|
|
onSave={handleEditorSave}
|
|
onClose={() => setEditorOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── 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,
|
|
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<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]);
|
|
|
|
return (
|
|
<>
|
|
{/* 종목 검색 패널 */}
|
|
{showMarket && (
|
|
<MarketSearchPanel
|
|
currentMarket={symbol}
|
|
onSelect={s => { onSymbol(s); setShowMarket(false); }}
|
|
onClose={() => setShowMarket(false)}
|
|
/>
|
|
)}
|
|
|
|
<div className="tv-toolbar">
|
|
{/* ── Left ─────────────────────────────────────────────────────────── */}
|
|
<div className="tv-toolbar-left">
|
|
|
|
{/* Symbol Search → 클릭 시 MarketSearchPanel 열기 */}
|
|
<div className="tv-sym-wrap">
|
|
<button
|
|
className="tv-sym-btn"
|
|
onClick={() => { setShowMarket(v => !v); setShowTfMenu(false); setShowCtMenu(false); setShowIndMenu(false); }}
|
|
title="종목 변경"
|
|
>
|
|
<IcSearch />
|
|
<span className="tv-sym-text">{getKoreanName(symbol)}</span>
|
|
</button>
|
|
</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 && (
|
|
<IndDropdown
|
|
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>
|
|
|
|
{/* 그리드 토글 */}
|
|
<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}
|
|
{s.source === 'local' && <span className="bt-drop-local-badge">로컬</span>}
|
|
</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 && (
|
|
<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>
|
|
<button className="tv-icon-btn" onClick={onFullscreen} title="전체화면">
|
|
<IcFullscreen />
|
|
</button>
|
|
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Toolbar;
|