Files
goldenChart/frontend/src/components/IndicatorModal.tsx
T

200 lines
7.8 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { INDICATOR_REGISTRY, type IndicatorDef, type IndicatorCategory } from '../utils/indicatorRegistry';
import type { IndicatorConfig } from '../types';
import { getNumericParamSpec } from '../utils/indicatorParamSpec';
import NumericParamInput from './NumericParamInput';
interface IndicatorModalProps {
activeIndicators: IndicatorConfig[];
onAdd: (def: IndicatorDef, params: Record<string, number | string | boolean>) => void;
onRemove: (id: string) => void;
/** type 단위 일괄 제거 (여러 인스턴스를 한 번에 제거) */
onRemoveByType?: (type: string) => void;
onClose: () => void;
}
const CATEGORIES: IndicatorCategory[] = [
'Moving Averages', 'Channels & Bands', 'Oscillators', 'Momentum',
'Trend', 'Volatility', 'Volume', 'Candlestick Patterns',
];
const IndicatorModal: React.FC<IndicatorModalProps> = ({
activeIndicators, onAdd, onRemove, onRemoveByType, onClose,
}) => {
const [search, setSearch] = useState('');
const [category, setCategory] = useState<IndicatorCategory | 'All'>('All');
const [selected, setSelected] = useState<IndicatorDef | null>(null);
const [params, setParams] = useState<Record<string, number | string | boolean>>({});
const filtered = useMemo(() => {
return INDICATOR_REGISTRY.filter(d => {
const matchCat = category === 'All' || d.category === category;
const matchQ = !search || d.name.toLowerCase().includes(search.toLowerCase()) || d.shortName.toLowerCase().includes(search.toLowerCase());
return matchCat && matchQ;
});
}, [search, category]);
const select = (def: IndicatorDef) => {
setSelected(def);
setParams({ ...def.defaultParams });
};
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
const handleAdd = () => {
if (!selected) return;
onAdd(selected, params);
setSelected(null);
};
const handleRemoveAll = (type: string) => {
if (onRemoveByType) {
onRemoveByType(type);
return;
}
activeIndicators.filter(a => a.type === type).forEach(a => onRemove(a.id));
};
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
return (
<div className="modal-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div
ref={panelRef}
className="indicator-modal"
style={{
...panelStyle,
zIndex: 5001,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header modal-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title modal-title">📈 지표 추가</span>
<span className="modal-subtitle">{INDICATOR_REGISTRY.length} 지표 지원</span>
<button type="button" className="gc-popup-close modal-close" onClick={onClose}></button>
</div>
<div className="modal-search">
<input
className="modal-search-input"
placeholder="지표 검색..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="modal-cats">
{(['All', ...CATEGORIES] as Array<'All' | IndicatorCategory>).map(c => (
<button
key={c}
className={`cat-btn ${category === c ? 'active' : ''}`}
onClick={() => setCategory(c)}
>{c === 'All' ? `전체 (${INDICATOR_REGISTRY.length})` : c} {c !== 'All' && `(${INDICATOR_REGISTRY.filter(d => d.category === c).length})`}</button>
))}
</div>
<div className="modal-body">
<div className="indicator-list">
{filtered.map(def => (
<div
key={def.type}
className={`indicator-item ${selected?.type === def.type ? 'selected' : ''} ${isActive(def.type) ? 'in-use' : ''}`}
onClick={() => select(def)}
>
<div className="indicator-item-left">
<span className="indicator-name">{def.name}</span>
<span className="indicator-desc">{def.description}</span>
</div>
<div className="indicator-item-right">
{def.overlay && <span className="badge overlay">오버레이</span>}
{def.returnsMarkers && <span className="badge markers">마커</span>}
{isActive(def.type) ? (
<button className="rm-btn" onClick={e => { e.stopPropagation(); handleRemoveAll(def.type); }}>제거</button>
) : (
<button className="add-btn" onClick={e => { e.stopPropagation(); select(def); onAdd(def, { ...def.defaultParams }); }}>+</button>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="no-results">'{search}' 검색 결과 없음</div>
)}
</div>
{selected && (
<div className="indicator-settings">
<div className="settings-title">{selected.name} 설정</div>
<div className="settings-desc">{selected.description}</div>
{Object.entries(params).map(([key, val]) => (
<div key={key} className="param-row">
<label className="param-label">{key}</label>
{typeof val === 'boolean' ? (
<input type="checkbox" checked={val as boolean} onChange={e => setParams(p => ({ ...p, [key]: e.target.checked }))} />
) : typeof val === 'number' ? (
<NumericParamInput
className="param-input"
value={val as number}
spec={getNumericParamSpec(selected.type, key, val as number)}
onChange={v => setParams(p => ({ ...p, [key]: v }))}
/>
) : (
<input
className="param-input"
type="text"
value={val as string}
onChange={e => setParams(p => ({ ...p, [key]: e.target.value }))}
/>
)}
</div>
))}
<div className="settings-actions">
<button className="settings-add-btn" onClick={handleAdd}>지표 추가</button>
<button className="settings-cancel" onClick={() => setSelected(null)}>취소</button>
</div>
{selected.plots.length > 0 && (
<div className="plot-preview">
{selected.plots.map(p => (
<div key={p.id} className="plot-legend-row">
<span className="plot-color" style={{ background: p.color }} />
<span className="plot-title">{p.title}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{activeIndicators.length > 0 && (
<div className="modal-active">
<div className="active-title">활성 지표 ({activeIndicators.length})</div>
<div className="active-list">
{activeIndicators.map(a => (
<span key={a.id} className="active-tag">
{a.type}
<button onClick={() => onRemove(a.id)}></button>
</span>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default IndicatorModal;