299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
/**
|
|
* 설정 화면 — 보조지표 기본값 (Main 16종 상단 + 전체 지표, 검색 필터)
|
|
*/
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import type { IndicatorConfig } from '../types';
|
|
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
|
import { buildMainIndicatorConfig } from '../utils/indicatorMainConfig';
|
|
import { indicatorDefaultsFingerprint } from '../utils/indicatorDefaultsFingerprint';
|
|
import IndicatorSettingsForm from './IndicatorSettingsForm';
|
|
import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor';
|
|
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
|
|
import {
|
|
getSettingsIndicatorTypes,
|
|
filterSettingsIndicatorTypes,
|
|
partitionFilteredTypes,
|
|
} from '../utils/indicatorSettingsList';
|
|
import IndicatorSettingsListSearch from './IndicatorSettingsListSearch';
|
|
import IndicatorSettingsListRow from './IndicatorSettingsListRow';
|
|
import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab';
|
|
import { useIndicatorListOrder } from '../hooks/useIndicatorListOrder';
|
|
|
|
export interface IndicatorSaveUiState {
|
|
save: () => Promise<void>;
|
|
dirty: boolean;
|
|
saving: boolean;
|
|
saveMessage: string | null;
|
|
}
|
|
|
|
export interface IndicatorMainDefaultsPanelProps {
|
|
chartIndicators: IndicatorConfig[];
|
|
onAddToChart: (type: string, config?: IndicatorConfig) => void;
|
|
onRemoveFromChart: (type: string) => void;
|
|
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
|
saveParams: (type: string, params: Record<string, number | string | boolean>) => void;
|
|
getVisualConfig: (
|
|
type: string,
|
|
defaultPlots?: PlotDef[],
|
|
defaultHlines?: HLineDef[],
|
|
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors };
|
|
saveVisual: (
|
|
type: string,
|
|
plots?: PlotDef[],
|
|
hlines?: HLineDef[],
|
|
cloudColors?: IchimokuCloudColors,
|
|
) => void;
|
|
/** DB에 보조지표 기본값 일괄 저장 */
|
|
saveAllDefaults?: (configs: Record<string, IndicatorConfig>) => Promise<void>;
|
|
/** 상단 저장 버튼 상태 (SettingsPage 헤더 연동) */
|
|
onSaveUiState?: (state: IndicatorSaveUiState | null) => void;
|
|
/** DB 로드 완료·저장 후 재로드 트리거 */
|
|
settingsRevision?: number;
|
|
/** 목록 순서 변경 시 차트 보조지표 순서 동기화 */
|
|
onListOrderChange?: (orderedTypes: string[]) => void;
|
|
}
|
|
|
|
const ALL_TYPES = getSettingsIndicatorTypes();
|
|
|
|
function buildDefaultsMap(
|
|
getParams: IndicatorMainDefaultsPanelProps['getParams'],
|
|
getVisualConfig: IndicatorMainDefaultsPanelProps['getVisualConfig'],
|
|
): Record<string, IndicatorConfig> {
|
|
const next: Record<string, IndicatorConfig> = {};
|
|
for (const type of ALL_TYPES) {
|
|
const cfg = buildMainIndicatorConfig(type, [], getParams, getVisualConfig);
|
|
if (cfg) next[type] = cfg;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function fingerprintMap(map: Record<string, IndicatorConfig>): string {
|
|
return ALL_TYPES.map(t => map[t] ? indicatorDefaultsFingerprint(map[t]) : '').join('|');
|
|
}
|
|
|
|
const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
|
|
chartIndicators,
|
|
onAddToChart,
|
|
onRemoveFromChart,
|
|
getParams,
|
|
getVisualConfig,
|
|
saveAllDefaults,
|
|
onSaveUiState,
|
|
settingsRevision = 0,
|
|
onListOrderChange,
|
|
}) => {
|
|
const onChartTypes = useMemo(() => new Set(chartIndicators.map(i => i.type)), [chartIndicators]);
|
|
const { listOrder, moveType } = useIndicatorListOrder(onListOrderChange);
|
|
const [draggingType, setDraggingType] = useState<string | null>(null);
|
|
const [dragOverType, setDragOverType] = useState<string | null>(null);
|
|
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(() => new Set());
|
|
const [configs, setConfigs] = useState<Record<string, IndicatorConfig>>({});
|
|
const [baselineFp, setBaselineFp] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
|
|
|
const reloadFromDb = useCallback(() => {
|
|
const next = buildDefaultsMap(getParams, getVisualConfig);
|
|
setConfigs(next);
|
|
setBaselineFp(fingerprintMap(next));
|
|
setSaveMessage(null);
|
|
}, [getParams, getVisualConfig]);
|
|
|
|
useEffect(() => {
|
|
reloadFromDb();
|
|
}, [reloadFromDb, settingsRevision]);
|
|
|
|
useEffect(() => {
|
|
const clearDrag = () => {
|
|
setDraggingType(null);
|
|
setDragOverType(null);
|
|
};
|
|
document.addEventListener('dragend', clearDrag);
|
|
return () => document.removeEventListener('dragend', clearDrag);
|
|
}, []);
|
|
|
|
const dirty = useMemo(
|
|
() => baselineFp !== '' && fingerprintMap(configs) !== baselineFp,
|
|
[configs, baselineFp],
|
|
);
|
|
|
|
const filtered = useMemo(
|
|
() => filterSettingsIndicatorTypes(listOrder, search),
|
|
[listOrder, search],
|
|
);
|
|
const { main: filteredMain, other: filteredOther } = useMemo(
|
|
() => partitionFilteredTypes(filtered),
|
|
[filtered],
|
|
);
|
|
|
|
const toggleExpand = useCallback((type: string) => {
|
|
setExpandedTypes(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(type)) next.delete(type);
|
|
else next.add(type);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleConfigChange = useCallback((type: string, updated: IndicatorConfig) => {
|
|
setConfigs(prev => ({ ...prev, [type]: updated }));
|
|
setSaveMessage(null);
|
|
}, []);
|
|
|
|
const handleToggleChart = useCallback((type: string, enabled: boolean) => {
|
|
if (enabled) {
|
|
onAddToChart(type, configs[type]);
|
|
} else {
|
|
onRemoveFromChart(type);
|
|
}
|
|
}, [configs, onAddToChart, onRemoveFromChart]);
|
|
|
|
const handleRowDefaults = useCallback((type: string) => {
|
|
const base = configs[type];
|
|
if (!base) return;
|
|
const reset = resetConfigToDefaults(base);
|
|
handleConfigChange(type, reset);
|
|
}, [configs, handleConfigChange]);
|
|
|
|
const handleResetAll = useCallback(() => {
|
|
if (!window.confirm('목록에 있는 모든 보조지표 기본값을 초기화할까요? (저장 버튼을 눌러야 DB에 반영됩니다)')) return;
|
|
const next: Record<string, IndicatorConfig> = {};
|
|
for (const type of ALL_TYPES) {
|
|
const base = configs[type] ?? buildMainIndicatorConfig(type, [], getParams, getVisualConfig);
|
|
if (!base) continue;
|
|
next[type] = resetConfigToDefaults(base);
|
|
}
|
|
setConfigs(next);
|
|
setSaveMessage(null);
|
|
}, [configs, getParams, getVisualConfig]);
|
|
|
|
const handleSaveAll = useCallback(async () => {
|
|
if (!saveAllDefaults) {
|
|
setSaveMessage('저장 API를 사용할 수 없습니다.');
|
|
return;
|
|
}
|
|
if (!dirty) return;
|
|
setSaving(true);
|
|
setSaveMessage(null);
|
|
try {
|
|
await saveAllDefaults(configs);
|
|
const fp = fingerprintMap(configs);
|
|
setBaselineFp(fp);
|
|
setSaveMessage('저장되었습니다.');
|
|
} catch (err) {
|
|
console.error('[IndicatorMainDefaultsPanel] save failed', err);
|
|
setSaveMessage('저장에 실패했습니다. 다시 시도해 주세요.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [saveAllDefaults, configs, dirty]);
|
|
|
|
useEffect(() => {
|
|
if (!onSaveUiState) return;
|
|
onSaveUiState({
|
|
save: handleSaveAll,
|
|
dirty,
|
|
saving,
|
|
saveMessage,
|
|
});
|
|
return () => onSaveUiState(null);
|
|
}, [onSaveUiState, handleSaveAll, dirty, saving, saveMessage]);
|
|
|
|
const canReorder = !search.trim();
|
|
|
|
const renderRows = (types: string[]) => types.map(type => {
|
|
const cfg = configs[type];
|
|
if (!cfg) return null;
|
|
return (
|
|
<IndicatorSettingsListRow
|
|
key={type}
|
|
type={type}
|
|
config={cfg}
|
|
enabled={onChartTypes.has(type)}
|
|
expanded={expandedTypes.has(type)}
|
|
variant="settings"
|
|
draggable={canReorder}
|
|
isDragOver={canReorder && dragOverType === type && draggingType !== type}
|
|
onDragHandleStart={e => {
|
|
setDraggingType(type);
|
|
e.dataTransfer.setData('text/indicator-type', type);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}}
|
|
onDragOver={e => {
|
|
if (!draggingType || draggingType === type) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
setDragOverType(type);
|
|
}}
|
|
onDragLeave={() => {
|
|
if (dragOverType === type) setDragOverType(null);
|
|
}}
|
|
onDrop={e => {
|
|
e.preventDefault();
|
|
const from = e.dataTransfer.getData('text/indicator-type') || draggingType;
|
|
if (from && from !== type) moveType(from, type, 'before');
|
|
setDraggingType(null);
|
|
setDragOverType(null);
|
|
}}
|
|
onToggleExpand={() => toggleExpand(type)}
|
|
onToggleEnabled={on => handleToggleChart(type, on)}
|
|
onChange={updated => handleConfigChange(type, updated)}
|
|
onRowDefaults={() => handleRowDefaults(type)}
|
|
/>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<div className="stg-ind-intro">
|
|
<p>
|
|
지표 추가 팝업 <strong>Main</strong> 탭 {MAIN_INDICATOR_TYPES.length}종을 상단에 두었고, 그 아래에 등록된 모든 보조지표가 있습니다.
|
|
우측 스위치로 차트에 바로 추가·제거할 수 있습니다. <strong>⠿</strong> 핸들로 목록 순서를 바꿀 수 있으며, 차트에 표시되는 보조지표 pane 순서도 이 목록을 따릅니다.
|
|
파라미터·색상을 변경한 뒤 <strong>상단 저장</strong> 버튼을 눌러 DB에 반영하세요.
|
|
</p>
|
|
<button type="button" className="stg-ind-reset-all" onClick={handleResetAll}>
|
|
전체 기본값 복원
|
|
</button>
|
|
</div>
|
|
|
|
<IndicatorSettingsListSearch
|
|
value={search}
|
|
onChange={setSearch}
|
|
totalCount={listOrder.length}
|
|
filteredCount={filtered.length}
|
|
className="stg-ind-search-block"
|
|
/>
|
|
|
|
<div className="stg-ind-list">
|
|
{filtered.length === 0 ? (
|
|
<div className="no-results ind-settings-no-results">
|
|
'{search}' 검색 결과 없음
|
|
</div>
|
|
) : (
|
|
<>
|
|
{filteredMain.length > 0 && (
|
|
<section className="ind-settings-section">
|
|
<h3 className="ind-settings-section-title">
|
|
Main 보조지표 <span className="ind-settings-section-count">{filteredMain.length}</span>
|
|
</h3>
|
|
{renderRows(filteredMain)}
|
|
</section>
|
|
)}
|
|
{filteredOther.length > 0 && (
|
|
<section className="ind-settings-section">
|
|
<h3 className="ind-settings-section-title">
|
|
기타 보조지표 <span className="ind-settings-section-count">{filteredOther.length}</span>
|
|
</h3>
|
|
{renderRows(filteredOther)}
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default IndicatorMainDefaultsPanel;
|