지표탭 추가
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
mergeIndicators,
|
||||
reorderIndicatorGroup,
|
||||
splitIndicatorPane,
|
||||
buildIndicatorConfigsFromTypes,
|
||||
} from '../utils/indicatorPaneMerge';
|
||||
import { saveSlot } from '../utils/backendApi';
|
||||
import type {
|
||||
@@ -85,6 +86,8 @@ function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; }
|
||||
export interface ChartSlotHandle {
|
||||
/** 지표 추가 */
|
||||
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
|
||||
/** 지표 일괄 추가 (탭 전체 추가) */
|
||||
addIndicators: (types: string[]) => void;
|
||||
/** 지표 제거 (type 기준) */
|
||||
removeIndicatorByType: (type: string) => void;
|
||||
/** 보조지표 pane 복사 */
|
||||
@@ -268,6 +271,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
return [...prev, cfg];
|
||||
});
|
||||
},
|
||||
addIndicators: (types: string[]) => {
|
||||
if (!types.length) return;
|
||||
setIndicators(prev => {
|
||||
const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId);
|
||||
return added.length > 0 ? [...prev, ...added] : prev;
|
||||
});
|
||||
},
|
||||
removeIndicatorByType: (type: string) => {
|
||||
setIndicators(prev => prev.filter(i => i.type !== type));
|
||||
},
|
||||
|
||||
@@ -27,9 +27,14 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Theme } from '../types/index';
|
||||
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import type { LogicNode } from '../utils/strategyTypes';
|
||||
import {
|
||||
buildStrategyEditorDef,
|
||||
buildStrategyEditorDefFromSettings,
|
||||
loadStratsLocal,
|
||||
saveStratsLocal,
|
||||
mergeAtRoot,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
genId,
|
||||
type StrategyDto,
|
||||
} from '../utils/strategyEditorShared';
|
||||
import { syncLogicNodeWithGlobalDef } from '../utils/strategyDefSync';
|
||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||
import {
|
||||
decodeConditionForEditor,
|
||||
@@ -128,7 +130,12 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
|
||||
}
|
||||
|
||||
export default function StrategyEditorPage({ theme }: Props) {
|
||||
const DEF = useMemo(() => buildStrategyEditorDef(), []);
|
||||
const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
|
||||
const DEF = useMemo(
|
||||
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
|
||||
[getParams, getVisualConfig, settingsRevision],
|
||||
);
|
||||
const settingsSyncRef = useRef(0);
|
||||
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
@@ -247,6 +254,36 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
||||
|
||||
/** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드 기준값 동기화 */
|
||||
useEffect(() => {
|
||||
if (settingsRevision <= settingsSyncRef.current) return;
|
||||
settingsSyncRef.current = settingsRevision;
|
||||
|
||||
const syncExtraRoots = (
|
||||
roots: Record<string, LogicNode | null>,
|
||||
signalType: 'buy' | 'sell',
|
||||
) => {
|
||||
const next: Record<string, LogicNode | null> = {};
|
||||
for (const [key, node] of Object.entries(roots)) {
|
||||
next[key] = node ? syncLogicNodeWithGlobalDef(node, DEF, signalType) : null;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
setBuyCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'buy') : prev));
|
||||
setSellCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'sell') : prev));
|
||||
setBuyOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'buy')));
|
||||
setSellOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'sell')));
|
||||
setBuyLayout(prev => ({
|
||||
...prev,
|
||||
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'buy'),
|
||||
}));
|
||||
setSellLayout(prev => ({
|
||||
...prev,
|
||||
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
|
||||
}));
|
||||
}, [settingsRevision, DEF]);
|
||||
|
||||
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
||||
|
||||
const persistFlowLayout = useCallback((strategyKey: string) => {
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface ToolbarProps {
|
||||
onFitContent: () => void;
|
||||
onScrollToNow?: () => void;
|
||||
onAddIndicator: (type: string) => void;
|
||||
onAddIndicators?: (types: string[]) => void;
|
||||
onRemoveByType: (type: string) => void;
|
||||
onAutoFib: () => void;
|
||||
onClearFib: () => void;
|
||||
@@ -302,13 +303,14 @@ const CHART_TYPES: { value: ChartType; label: string }[] = [
|
||||
interface IndDropdownProps {
|
||||
activeIndicators: IndicatorConfig[];
|
||||
onAdd: (type: string) => void;
|
||||
onAddMany?: (types: string[]) => void;
|
||||
onRemove: (type: string) => void;
|
||||
onOpenSettings?: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
activeIndicators, onAdd, onRemove, onOpenSettings, onClose,
|
||||
activeIndicators, onAdd, onAddMany, onRemove, onOpenSettings, onClose,
|
||||
}) => {
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [category, setCategory] = React.useState<IndicatorTabKey>('All');
|
||||
@@ -316,6 +318,7 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
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());
|
||||
@@ -358,13 +361,63 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const addAllContext = React.useMemo(() => {
|
||||
if (category === 'All') return null;
|
||||
if (category === 'Main') {
|
||||
return { label: '주요지표', types: [...MAIN_INDICATOR_TYPES] };
|
||||
}
|
||||
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;
|
||||
const validTypes = addAllContext.types.filter(
|
||||
type => INDICATOR_REGISTRY.some(d => d.type === type),
|
||||
);
|
||||
const toAdd = validTypes.filter(type => !isActive(type));
|
||||
if (toAdd.length === 0) {
|
||||
window.alert('추가할 지표가 없습니다. (이미 모두 차트에 있습니다)');
|
||||
return;
|
||||
}
|
||||
const msg = `${addAllContext.label} 탭에 있는 지표 ${toAdd.length}개를 차트에 추가하시겠습니까?`;
|
||||
if (!window.confirm(msg)) return;
|
||||
if (onAddMany) {
|
||||
onAddMany(toAdd);
|
||||
} else {
|
||||
toAdd.forEach(type => onAdd(type));
|
||||
}
|
||||
};
|
||||
|
||||
const stopHeaderBtn = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editorOpen || searchFocusedRef.current) return;
|
||||
searchRef.current?.focus();
|
||||
// ESC 키로 닫기
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
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]);
|
||||
}, [onClose, editorOpen]);
|
||||
|
||||
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
|
||||
|
||||
@@ -429,7 +482,35 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title">지표 추가</span>
|
||||
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
||||
<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>
|
||||
|
||||
{/* 검색창 */}
|
||||
@@ -451,7 +532,7 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<div className="ind-panel-cats-row">
|
||||
<div className="ind-panel-cats">
|
||||
<div className="ind-panel-cats" role="tablist" aria-label="지표 탭">
|
||||
{BUILTIN_TABS.map(c => {
|
||||
const cnt = c.key === 'All'
|
||||
? INDICATOR_REGISTRY.length
|
||||
@@ -460,6 +541,8 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
<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(''); }}
|
||||
>
|
||||
@@ -472,6 +555,8 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
<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(''); }}
|
||||
>
|
||||
@@ -480,27 +565,16 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ind-panel-cats-actions">
|
||||
<button type="button" className="ind-panel-cat-action" onClick={openCreateEditor}>
|
||||
추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-cat-action"
|
||||
disabled={!selectedCustomTab}
|
||||
onClick={openEditEditor}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-cat-action danger"
|
||||
disabled={!selectedCustomTab}
|
||||
onClick={handleDeleteTab}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ind-panel-add-all"
|
||||
disabled={addAllDisabled}
|
||||
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '현재 탭의 모든 지표 추가'}
|
||||
aria-label="현재 탭의 모든 지표 추가"
|
||||
onClick={handleAddAllInTab}
|
||||
>
|
||||
<IcPlus />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 지표 목록 */}
|
||||
@@ -618,7 +692,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
symbol, timeframe, chartType, theme, mode, logScale, showGrid,
|
||||
activeIndicators,
|
||||
onSymbol, onTimeframe, onChartType, onTheme, onMode,
|
||||
onFitContent, onScrollToNow, onAddIndicator, onRemoveByType,
|
||||
onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onRemoveByType,
|
||||
onAutoFib, onClearFib, onScreenshot,
|
||||
onToggleStats, onToggleWatch, onToggleAlert,
|
||||
tradeNotifyUnread = 0, onOpenTradeNotifications,
|
||||
@@ -795,6 +869,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<IndDropdown
|
||||
activeIndicators={activeIndicators}
|
||||
onAdd={type => onAddIndicator(type)}
|
||||
onAddMany={onAddIndicators}
|
||||
onRemove={type => onRemoveByType(type)}
|
||||
onOpenSettings={onOpenIndicatorSettings}
|
||||
onClose={() => setShowIndMenu(false)}
|
||||
|
||||
Reference in New Issue
Block a user