지표탭 추가

This commit is contained in:
Macbook
2026-05-27 09:32:26 +09:00
parent d0985f8f0f
commit 8876dbd752
12 changed files with 595 additions and 73 deletions
+10
View File
@@ -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(() => {
+39 -2
View File
@@ -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) => {
+103 -28
View File
@@ -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)}