563 lines
20 KiB
TypeScript
563 lines
20 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Button,
|
|
Box,
|
|
Typography,
|
|
Switch,
|
|
FormControlLabel,
|
|
Divider,
|
|
useTheme,
|
|
useMediaQuery,
|
|
Alert,
|
|
Paper,
|
|
TextField,
|
|
Slider,
|
|
IconButton,
|
|
} from '@mui/material';
|
|
import { DragIndicator, Close } from '@mui/icons-material';
|
|
import { useChartColors, ChartColorSettings } from '../contexts/ChartColorContext';
|
|
import { useIndicatorSettings, IndicatorSettings } from '../contexts/IndicatorSettingsContext';
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
TouchSensor,
|
|
useSensor,
|
|
useSensors,
|
|
DragEndEvent,
|
|
DragStartEvent,
|
|
DragOverlay,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { useIndicatorVisibility, IndicatorVisibilitySettings, IndicatorOrder } from '../contexts/IndicatorVisibilityContext';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import DraggablePaper from './DraggablePaper';
|
|
|
|
interface IndicatorSettingsDialogProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// 지표별 색상/굵기 설정 키 매핑 (여러 색상 지원)
|
|
interface ColorConfig {
|
|
label: string;
|
|
colorKey: keyof ChartColorSettings;
|
|
widthKey?: keyof ChartColorSettings;
|
|
}
|
|
|
|
const indicatorColorConfigs: Record<string, ColorConfig[]> = {
|
|
macdLine: [
|
|
{ label: 'MACD', colorKey: 'macdLine', widthKey: 'macdLineWidth' },
|
|
{ label: '시그널', colorKey: 'signalLine', widthKey: 'signalLineWidth' },
|
|
],
|
|
rsi: [
|
|
{ label: '', colorKey: 'rsiColor', widthKey: 'rsiWidth' },
|
|
],
|
|
stochasticK: [
|
|
{ label: '%K', colorKey: 'stochasticK', widthKey: 'stochasticKWidth' },
|
|
{ label: '%D', colorKey: 'stochasticD', widthKey: 'stochasticDWidth' },
|
|
],
|
|
cci: [
|
|
{ label: '', colorKey: 'cciColor', widthKey: 'cciWidth' },
|
|
],
|
|
adx: [
|
|
{ label: '', colorKey: 'adxColor', widthKey: 'adxWidth' },
|
|
],
|
|
plusDi: [
|
|
{ label: '+DI', colorKey: 'plusDiColor', widthKey: 'plusDiWidth' },
|
|
{ label: '-DI', colorKey: 'minusDiColor', widthKey: 'minusDiWidth' },
|
|
],
|
|
minusDi: [], // plusDi에서 함께 처리
|
|
williamsR: [
|
|
{ label: '', colorKey: 'williamsRColor', widthKey: 'williamsRWidth' },
|
|
],
|
|
trix: [
|
|
{ label: 'TRIX', colorKey: 'trixColor', widthKey: 'trixWidth' },
|
|
{ label: '시그널', colorKey: 'trixSignalColor', widthKey: 'trixSignalWidth' },
|
|
],
|
|
newPsychological: [
|
|
{ label: '', colorKey: 'newPsychologicalColor', widthKey: 'newPsychologicalWidth' },
|
|
],
|
|
investPsychological: [
|
|
{ label: '', colorKey: 'investPsychologicalColor', widthKey: 'investPsychologicalWidth' },
|
|
],
|
|
bwi: [
|
|
{ label: '', colorKey: 'bwiColor', widthKey: 'bwiWidth' },
|
|
],
|
|
obv: [
|
|
{ label: '', colorKey: 'obvColor', widthKey: 'obvWidth' },
|
|
],
|
|
volumeOsc: [
|
|
{ label: '', colorKey: 'volumeOscColor', widthKey: 'volumeOscWidth' },
|
|
],
|
|
vr: [
|
|
{ label: '', colorKey: 'vrColor', widthKey: 'vrWidth' },
|
|
],
|
|
disparity: [
|
|
{ label: '초단기', colorKey: 'disparityUltraShortColor', widthKey: 'disparityUltraShortWidth' },
|
|
{ label: '단기', colorKey: 'disparityShortColor', widthKey: 'disparityShortWidth' },
|
|
{ label: '중기', colorKey: 'disparityMidColor', widthKey: 'disparityMidWidth' },
|
|
{ label: '장기', colorKey: 'disparityLongColor', widthKey: 'disparityLongWidth' },
|
|
],
|
|
};
|
|
|
|
// 지표별 파라미터 키 매핑
|
|
const indicatorParamKeys: Record<string, { key: string; label: string }[]> = {
|
|
macdLine: [{ key: 'macdFastPeriod', label: '단기 EMA' }, { key: 'macdSlowPeriod', label: '장기 EMA' }, { key: 'macdSignalPeriod', label: '시그널' }],
|
|
rsi: [{ key: 'rsiPeriod', label: '기간' }],
|
|
stochasticK: [{ key: 'stochasticKPeriod', label: '기간' }, { key: 'stochasticSlowing', label: '%K' }, { key: 'stochasticDPeriod', label: '%D' }],
|
|
cci: [{ key: 'cciPeriod', label: '기간' }, { key: 'cciSignalPeriod', label: '시그널' }],
|
|
adx: [{ key: 'adxPeriod', label: '기간' }],
|
|
williamsR: [{ key: 'williamsRPeriod', label: '기간' }],
|
|
trix: [{ key: 'trixPeriod', label: '기간' }, { key: 'trixSignalPeriod', label: '시그널' }],
|
|
newPsychological: [{ key: 'newPsychologicalPeriod', label: '기간' }],
|
|
investPsychological: [{ key: 'investPsychologicalPeriod', label: '기간' }],
|
|
bwi: [{ key: 'bwiPeriod', label: '기간' }],
|
|
obv: [{ key: 'obvPeriod', label: '기간' }],
|
|
volumeOsc: [{ key: 'volumeOscShortPeriod', label: '단기' }, { key: 'volumeOscLongPeriod', label: '장기' }],
|
|
vr: [{ key: 'vrPeriod', label: '기간' }],
|
|
disparity: [
|
|
{ key: 'disparityUltraShortPeriod', label: '초단기' },
|
|
{ key: 'disparityShortPeriod', label: '단기' },
|
|
{ key: 'disparityMidPeriod', label: '중기' },
|
|
{ key: 'disparityLongPeriod', label: '장기' },
|
|
],
|
|
plusDi: [{ key: 'dmiPeriod', label: '기간' }],
|
|
minusDi: [{ key: 'dmiPeriod', label: '기간' }],
|
|
};
|
|
|
|
// Sortable 아이템 컴포넌트 - 이동평균선 설정과 동일한 스타일
|
|
interface SortableItemProps {
|
|
indicator: IndicatorOrder;
|
|
isDisabled: boolean;
|
|
checked: boolean;
|
|
onToggle: () => void;
|
|
colors: ChartColorSettings;
|
|
updateColors: (c: Partial<ChartColorSettings>) => void;
|
|
indicatorSettings: IndicatorSettings;
|
|
updateIndicatorSettings: (s: Partial<IndicatorSettings>) => void;
|
|
}
|
|
|
|
const SortableItem: React.FC<SortableItemProps> = ({
|
|
indicator,
|
|
isDisabled,
|
|
checked,
|
|
onToggle,
|
|
colors,
|
|
updateColors,
|
|
indicatorSettings,
|
|
updateIndicatorSettings,
|
|
}) => {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: indicator.id, disabled: isDisabled || indicator.settingKey === 'candleChart' });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
};
|
|
|
|
const canDrag = !isDisabled && indicator.settingKey !== 'candleChart';
|
|
const colorConfigs = indicatorColorConfigs[indicator.id] || [];
|
|
const paramKeys = indicatorParamKeys[indicator.id];
|
|
const hasSettings = indicator.settingKey !== 'candleChart' && indicator.settingKey !== 'volume';
|
|
|
|
// 첫 번째 색상/굵기 설정
|
|
const primaryColor = colorConfigs[0];
|
|
// 추가 색상 설정 (시그널 등)
|
|
const secondaryColors = colorConfigs.slice(1);
|
|
|
|
return (
|
|
<Paper
|
|
ref={setNodeRef}
|
|
style={style}
|
|
sx={{
|
|
mb: 1,
|
|
border: isDragging ? '2px solid #1976d2' : checked ? '1px solid' : '1px solid',
|
|
borderColor: isDragging ? '#1976d2' : checked ? 'primary.main' : 'divider',
|
|
borderRadius: 1,
|
|
bgcolor: isDragging ? 'action.hover' : checked ? 'action.selected' : 'background.paper',
|
|
boxShadow: isDragging ? 4 : 1,
|
|
opacity: checked ? 1 : 0.7,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
px: 1.5,
|
|
py: 1.2,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.5,
|
|
minHeight: 48,
|
|
}}
|
|
>
|
|
{/* 사용 체크박스 */}
|
|
<Box sx={{ width: 40, display: 'flex', justifyContent: 'center' }}>
|
|
<Switch
|
|
checked={checked}
|
|
onChange={onToggle}
|
|
disabled={indicator.settingKey === 'candleChart'}
|
|
size="small"
|
|
/>
|
|
</Box>
|
|
|
|
{/* 드래그 핸들 + 지표명 */}
|
|
<Box sx={{ width: 100, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
{canDrag && (
|
|
<Box
|
|
{...attributes}
|
|
{...listeners}
|
|
sx={{ cursor: 'grab', '&:active': { cursor: 'grabbing' }, display: 'flex' }}
|
|
>
|
|
<DragIndicator color="action" sx={{ fontSize: 18 }} />
|
|
</Box>
|
|
)}
|
|
<Typography variant="body2" sx={{ fontWeight: checked ? 'bold' : 'normal', fontSize: '13px' }}>
|
|
{indicator.label}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* 파라미터 영역 */}
|
|
<Box sx={{ width: 180, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
{hasSettings && paramKeys && paramKeys.map((param, idx) => (
|
|
<TextField
|
|
key={param.key}
|
|
type="number"
|
|
size="small"
|
|
placeholder={param.label}
|
|
value={(indicatorSettings as any)[param.key] || ''}
|
|
onChange={(e) => updateIndicatorSettings({ [param.key]: Number(e.target.value) })}
|
|
onClick={(e) => e.stopPropagation()}
|
|
disabled={!checked}
|
|
inputProps={{ min: 1, max: 999, style: { fontSize: 12, padding: '6px 8px', textAlign: 'center' } }}
|
|
sx={{
|
|
width: paramKeys.length > 2 ? 50 : 60,
|
|
'& .MuiOutlinedInput-root': { bgcolor: 'background.default' },
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
{/* 색상 영역 */}
|
|
<Box sx={{ width: 100, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
{hasSettings && primaryColor && (
|
|
<Box sx={{ position: 'relative' }}>
|
|
<Box
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 1,
|
|
bgcolor: colors[primaryColor.colorKey] as string,
|
|
border: '2px solid',
|
|
borderColor: 'divider',
|
|
cursor: checked ? 'pointer' : 'default',
|
|
opacity: checked ? 1 : 0.5,
|
|
}}
|
|
/>
|
|
<input
|
|
type="color"
|
|
value={colors[primaryColor.colorKey] as string || '#666666'}
|
|
onChange={(e) => updateColors({ [primaryColor.colorKey]: e.target.value })}
|
|
onClick={(e) => e.stopPropagation()}
|
|
disabled={!checked}
|
|
style={{
|
|
position: 'absolute', top: 0, left: 0,
|
|
width: 32, height: 32, opacity: 0, cursor: 'pointer'
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
{/* 추가 색상 (시그널 등) */}
|
|
{hasSettings && secondaryColors.map((cfg) => (
|
|
<Box key={cfg.colorKey} sx={{ position: 'relative' }}>
|
|
<Box
|
|
sx={{
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 0.5,
|
|
bgcolor: colors[cfg.colorKey] as string,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
cursor: checked ? 'pointer' : 'default',
|
|
opacity: checked ? 1 : 0.5,
|
|
}}
|
|
/>
|
|
<input
|
|
type="color"
|
|
value={colors[cfg.colorKey] as string || '#666666'}
|
|
onChange={(e) => updateColors({ [cfg.colorKey]: e.target.value })}
|
|
onClick={(e) => e.stopPropagation()}
|
|
disabled={!checked}
|
|
style={{
|
|
position: 'absolute', top: 0, left: 0,
|
|
width: 24, height: 24, opacity: 0, cursor: 'pointer'
|
|
}}
|
|
/>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
{/* 선 굵기 영역 */}
|
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
{hasSettings && primaryColor?.widthKey && (
|
|
<>
|
|
<Slider
|
|
value={colors[primaryColor.widthKey] as number || 1}
|
|
onChange={(_, value) => updateColors({ [primaryColor.widthKey!]: value as number })}
|
|
onClick={(e) => e.stopPropagation()}
|
|
disabled={!checked}
|
|
min={0.5}
|
|
max={5}
|
|
step={0.5}
|
|
sx={{ flex: 1, maxWidth: 150 }}
|
|
size="small"
|
|
/>
|
|
<Typography variant="caption" sx={{ minWidth: 30, fontSize: '11px', color: 'text.secondary' }}>
|
|
{colors[primaryColor.widthKey]}px
|
|
</Typography>
|
|
{/* 선 미리보기 */}
|
|
<Box
|
|
sx={{
|
|
width: 40,
|
|
height: Math.max((colors[primaryColor.widthKey] as number || 1) * 2, 2),
|
|
bgcolor: checked ? colors[primaryColor.colorKey] as string : 'grey.400',
|
|
borderRadius: 0.5,
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
};
|
|
|
|
const IndicatorSettingsDialog: React.FC<IndicatorSettingsDialogProps> = ({ open, onClose }) => {
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
const { settings, order, updateSettings, updateOrder, resetSettings, saveSettings } = useIndicatorVisibility();
|
|
const { colors, updateColors } = useChartColors();
|
|
const { indicatorSettings, updateIndicatorSettings } = useIndicatorSettings();
|
|
const { isAuthenticated } = useAuth();
|
|
const [tempSettings, setTempSettings] = useState<IndicatorVisibilitySettings>(settings);
|
|
const [tempOrder, setTempOrder] = useState<IndicatorOrder[]>(order);
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
// 드래그 센서 설정 - 즉시 드래그 시작 가능하도록 설정
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 5, // 5px 이동 후 드래그 시작
|
|
},
|
|
}),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: {
|
|
delay: 100, // 100ms 터치 후 드래그 시작
|
|
tolerance: 5,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
})
|
|
);
|
|
|
|
// 다이얼로그가 열릴 때마다 현재 설정으로 초기화
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
setTempSettings(settings);
|
|
setTempOrder(order);
|
|
}
|
|
}, [open, settings, order]);
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
setActiveId(event.active.id as string);
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
setActiveId(null);
|
|
|
|
if (over && active.id !== over.id) {
|
|
setTempOrder((items) => {
|
|
const oldIndex = items.findIndex((item) => item.id === active.id);
|
|
const newIndex = items.findIndex((item) => item.id === over.id);
|
|
|
|
return arrayMove(items, oldIndex, newIndex);
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleToggle = (key: keyof IndicatorVisibilitySettings) => {
|
|
// candleChart는 변경 불가
|
|
if (key === 'candleChart') return;
|
|
|
|
setTempSettings(prev => ({ ...prev, [key]: !prev[key] }));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
updateSettings(tempSettings);
|
|
updateOrder(tempOrder);
|
|
await saveSettings(isAuthenticated);
|
|
onClose();
|
|
};
|
|
|
|
const handleReset = () => {
|
|
if (window.confirm('모든 보조지표 설정을 기본값으로 초기화하시겠습니까?')) {
|
|
resetSettings();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={onClose}
|
|
maxWidth="md"
|
|
fullWidth
|
|
fullScreen={isMobile}
|
|
hideBackdrop={true}
|
|
disableScrollLock={true}
|
|
PaperComponent={DraggablePaper}
|
|
sx={{
|
|
pointerEvents: 'none',
|
|
'& .MuiDialog-container': {
|
|
pointerEvents: 'none',
|
|
alignItems: 'flex-start',
|
|
},
|
|
}}
|
|
PaperProps={{
|
|
sx: {
|
|
minHeight: isMobile ? '100%' : '60vh',
|
|
maxHeight: isMobile ? '100%' : '90vh',
|
|
width: isMobile ? '100%' : 750,
|
|
pointerEvents: 'auto',
|
|
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
|
border: '2px solid rgba(80, 100, 160, 0.5)',
|
|
}
|
|
}}
|
|
>
|
|
<DialogTitle sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
bgcolor: '#1976d2',
|
|
color: '#fff',
|
|
py: 1.2,
|
|
}}>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
|
|
📊 보조지표 표시 설정
|
|
</Typography>
|
|
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
|
|
<Close />
|
|
</IconButton>
|
|
</DialogTitle>
|
|
<DialogContent dividers>
|
|
{/* 헤더 행 - 이동평균선 설정과 동일한 스타일 */}
|
|
<Paper sx={{ p: 1.5, mb: 2, bgcolor: 'action.hover' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Box sx={{ width: 40, textAlign: 'center' }}>
|
|
<Typography variant="caption" fontWeight="bold" color="text.secondary">사용</Typography>
|
|
</Box>
|
|
<Box sx={{ width: 100 }}>
|
|
<Typography variant="caption" fontWeight="bold" color="text.secondary">이름</Typography>
|
|
</Box>
|
|
<Box sx={{ width: 180 }}>
|
|
<Typography variant="caption" fontWeight="bold" color="text.secondary">파라미터</Typography>
|
|
</Box>
|
|
<Box sx={{ width: 100 }}>
|
|
<Typography variant="caption" fontWeight="bold" color="text.secondary">색상</Typography>
|
|
</Box>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Typography variant="caption" fontWeight="bold" color="text.secondary">선 굵기</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={tempOrder.map(item => item.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{tempOrder.map((indicator) => {
|
|
const value = tempSettings[indicator.settingKey];
|
|
const isChecked = typeof value === 'boolean' ? value : false;
|
|
return (
|
|
<SortableItem
|
|
key={indicator.id}
|
|
indicator={indicator}
|
|
isDisabled={false}
|
|
checked={isChecked}
|
|
onToggle={() => handleToggle(indicator.settingKey)}
|
|
colors={colors}
|
|
updateColors={updateColors}
|
|
indicatorSettings={indicatorSettings}
|
|
updateIndicatorSettings={updateIndicatorSettings}
|
|
/>
|
|
);
|
|
})}
|
|
</SortableContext>
|
|
<DragOverlay>
|
|
{activeId ? (
|
|
<Paper
|
|
sx={{
|
|
p: 1.5,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
border: '2px solid #1976d2',
|
|
borderRadius: 1,
|
|
bgcolor: 'background.paper',
|
|
boxShadow: 6,
|
|
opacity: 0.9,
|
|
}}
|
|
>
|
|
<DragIndicator color="primary" sx={{ mr: 1.5 }} />
|
|
<Typography variant="body1">
|
|
{tempOrder.find(item => item.id === activeId)?.label}
|
|
</Typography>
|
|
</Paper>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</DialogContent>
|
|
<DialogActions sx={{ p: 2 }}>
|
|
<Button onClick={handleReset} color="error" variant="outlined">
|
|
초기화
|
|
</Button>
|
|
<Box sx={{ flex: 1 }} />
|
|
<Button onClick={onClose} variant="outlined">
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} variant="contained" color="primary">
|
|
저장
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default IndicatorSettingsDialog;
|
|
|