/** * 보조지표 설정 폼 — 개별 설정 모달·일괄 설정 팝업에서 동일 UI/로직 공유 */ import React, { useState, useCallback, useEffect, useRef } from 'react'; import type { IndicatorConfig, Timeframe } from '../types'; import { getIndicatorDef } from '../utils/indicatorRegistry'; import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; import { getNumericParamSpec } from '../utils/indicatorParamSpec'; import NumericParamInput from './NumericParamInput'; import SmaPeriodInput from './SmaPeriodInput'; import { SMA_DEFAULT_SRC, SMA_MA_COUNT, smaPeriodKey, smaPlotId, formatSmaMaDisplayLabel, } from '../utils/smaConfig'; import PlotLineStylePicker from './PlotLineStylePicker'; import { GlobalParamRow, IchimokuCloudSection, BollingerSymbolSection, BollingerBackgroundRow, SettingsOutputFooter, UnifiedIndicatorBody, } from './IndicatorSettingsSections'; import { createEditorSnapshot, configFromEditorSnapshot, resetConfigToDefaults, type IndicatorSettingsEditorSnapshot, } from '../utils/indicatorSettingsEditor'; export const ALL_TIMEFRAMES: { tf: Timeframe; label: string }[] = [ { tf: '1m', label: '1분' }, { tf: '3m', label: '3분' }, { tf: '5m', label: '5분' }, { tf: '10m', label: '10분' }, { tf: '15m', label: '15분' }, { tf: '30m', label: '30분' }, { tf: '1h', label: '1시간' }, { tf: '4h', label: '4시간' }, { tf: '1D', label: '일봉' }, { tf: '1W', label: '주봉' }, { tf: '1M', label: '월봉' }, ]; export interface IndicatorSettingsFormProps { config: IndicatorConfig; chartMarket?: string; /** embedded: 일괄 설정 카드 안 */ variant?: 'modal' | 'embedded'; /** * modal: 편집 중 부모 setState 없이 ref만 갱신 (포커스 유지). * embedded: 변경 즉시 onChange 호출. */ deferParentSync?: boolean; /** @deprecated 탭 제거 — 통합 화면만 사용 */ inputsOnly?: boolean; /** 개별 설정 모달: 지표 전체 표시 여부 */ chartHidden?: boolean; onChartHiddenChange?: (showOnChart: boolean) => void; onChange: (updated: IndicatorConfig) => void; } function configFingerprint(c: IndicatorConfig): string { return JSON.stringify({ id: c.id, params: c.params, plots: c.plots, plotVisibility: c.plotVisibility, hlines: c.hlines, hlinesBackground: c.hlinesBackground, bandBackground: c.bandBackground, cloudColors: c.cloudColors, lastValueVisible: c.lastValueVisible, timeframeVisibility: c.timeframeVisibility, hidden: c.hidden, }); } const IndicatorSettingsForm: React.FC = ({ config, chartMarket = '', variant = 'modal', deferParentSync = variant === 'modal', inputsOnly = false, chartHidden = false, onChartHiddenChange, onChange, }) => { const def = getIndicatorDef(config.type); const isSma = config.type === 'SMA'; const isIchimoku = config.type === 'IchimokuCloud'; const isBollinger = config.type === 'BollingerBands'; const [snap, setSnap] = useState(() => createEditorSnapshot(config)); const baseRef = useRef(config); const fpRef = useRef(configFingerprint(config)); const onChangeRef = useRef(onChange); const internalEmitRef = useRef(false); onChangeRef.current = onChange; const emitDraft = useCallback((next: IndicatorSettingsEditorSnapshot) => { const updated = configFromEditorSnapshot(baseRef.current, next); baseRef.current = updated; if (deferParentSync) { onChangeRef.current(updated); return; } internalEmitRef.current = true; onChangeRef.current(updated); }, [deferParentSync]); const scheduleDraftEmit = useCallback((next: IndicatorSettingsEditorSnapshot) => { if (deferParentSync) { emitDraft(next); return; } emitDraft(next); }, [deferParentSync, emitDraft]); useEffect(() => { if (deferParentSync) return; if (internalEmitRef.current) { internalEmitRef.current = false; fpRef.current = configFingerprint(config); baseRef.current = config; return; } const fp = configFingerprint(config); if (fp !== fpRef.current) { fpRef.current = fp; baseRef.current = config; setSnap(createEditorSnapshot(config)); } }, [config, deferParentSync]); const emit = useCallback((next: IndicatorSettingsEditorSnapshot) => { setSnap(next); scheduleDraftEmit(next); }, [scheduleDraftEmit]); const patch = useCallback((partial: Partial) => { setSnap(prev => { const next = { ...prev, ...partial }; scheduleDraftEmit(next); return next; }); }, [scheduleDraftEmit]); const { params, plots, plotVis, hlines, hlinesBg, lastValueVisible, timeframeVis, cloudColors, bandBg } = snap; const hasHLines = (def?.hlines ?? []).length > 0 || hlines.length > 0; const handleHLineVisible = useCallback((idx: number, visible: boolean) => patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, visible } : h) }), [hlines, patch]); const handleHLinePrice = useCallback((idx: number, price: number) => patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, price } : h) }), [hlines, patch]); const handleHLinePlotStyle = useCallback((idx: number, p: Partial) => patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, ...p } : h) }), [hlines, patch]); const handleParamChange = useCallback((key: string, raw: string | boolean) => { const oldVal = params[key]; const defVal = def?.defaultParams?.[key]; let nextParams: Record; if (typeof oldVal === 'number' || typeof defVal === 'number') { const n = parseFloat(raw as string); const fallback = typeof oldVal === 'number' ? oldVal : (defVal as number); nextParams = { ...params, [key]: isNaN(n) ? fallback : n }; } else if (typeof oldVal === 'boolean' || typeof defVal === 'boolean') { nextParams = { ...params, [key]: raw as boolean }; } else { nextParams = { ...params, [key]: raw as string }; } let nextPlotVis = plotVis; if (key === 'maType' && (config.type === 'CCI' || config.type === 'RSI')) { nextPlotVis = { ...plotVis, plot1: raw !== 'None' }; } patch({ params: nextParams, plotVis: nextPlotVis }); }, [params, plotVis, def, config.type, patch]); const handlePlotStyle = useCallback((idx: number, p: Partial) => { patch({ plots: plots.map((pl, i) => i === idx ? { ...pl, ...p } : pl) }); }, [plots, patch]); const handlePlotToggle = useCallback((plotId: string, enabled: boolean) => { patch({ plotVis: { ...plotVis, [plotId]: enabled } }); }, [plotVis, patch]); const handleSmaPeriod = useCallback((maIndex: number, v: number) => { const nextParams = { ...params, [smaPeriodKey(maIndex)]: v }; const nextPlots = plots.map((pl, i) => i === maIndex - 1 ? { ...pl, title: formatSmaMaDisplayLabel(nextParams, maIndex) } : pl, ); patch({ params: nextParams, plots: nextPlots }); }, [params, plots, patch]); const handleSmaToggle = useCallback((plotIndex: number, enabled: boolean) => { patch({ plotVis: { ...plotVis, [smaPlotId(plotIndex)]: enabled } }); }, [plotVis, patch]); const handleDefaults = useCallback(() => { const reset = resetConfigToDefaults(config); baseRef.current = reset; fpRef.current = configFingerprint(reset); const nextSnap = createEditorSnapshot(reset); setSnap(nextSnap); onChange(reset); }, [config, onChange]); const footerProps = { lastValueVisible, onLastValueVisible: (v: boolean) => patch({ lastValueVisible: v }), timeframeVis, onTimeframeVis: (tf: Timeframe, v: boolean) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } }), timeframes: ALL_TIMEFRAMES, chartHidden, onChartHidden: onChartHiddenChange, }; const sectionClass = variant === 'embedded' ? 'ism-section unified-settings-section ibsm-embedded-form' : 'ism-section unified-settings-section'; return (
{variant === 'embedded' && (
)} {isSma && (
patch({ params: { ...params, src: raw as string } })} />
{Array.from({ length: SMA_MA_COUNT }, (_, i) => { const maNum = i + 1; const plotId = smaPlotId(i); const periodKey = smaPeriodKey(maNum); const enabled = plotVis[plotId] !== false; const plot = plots[i]; return (
e.stopPropagation()} onPointerDown={e => e.stopPropagation()} > handleSmaPeriod(maNum, v)} /> {plot && !inputsOnly && ( handlePlotStyle(i, p)} /> )}
); })} {!inputsOnly && }
)} {isIchimoku && ( patch({ hlinesBg: v })} onLastValueVisible={v => patch({ lastValueVisible: v })} onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })} chartHidden={chartHidden} onChartHidden={onChartHiddenChange} cloudSection={inputsOnly ? undefined : ( patch({ cloudColors: { ...cloudColors, ...p } })} /> )} inputsOnly={inputsOnly} showOutputFooter={!inputsOnly} /> )} {!isSma && !isIchimoku && ( patch({ hlinesBg: v })} onLastValueVisible={v => patch({ lastValueVisible: v })} onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })} chartHidden={chartHidden} onChartHidden={onChartHiddenChange} inputsOnly={inputsOnly} showOutputFooter={!inputsOnly && !isBollinger} symbolSection={isBollinger ? ( { const next = { ...params }; if (p.symbolMode !== undefined) next.symbolMode = p.symbolMode; if (p.refSymbol !== undefined) next.refSymbol = p.refSymbol; patch({ params: next }); }} /> ) : undefined} afterPlotsSection={isBollinger ? ( patch({ bandBg: v })} /> ) : undefined} /> )}
); }; export default IndicatorSettingsForm;