370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
/**
|
|
* 보조지표 설정 폼 — 개별 설정 모달·일괄 설정 팝업에서 동일 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 { getPlotLabel } from '../utils/indicatorLabels';
|
|
import { getNumericParamSpec } from '../utils/indicatorParamSpec';
|
|
import NumericParamInput from './NumericParamInput';
|
|
import SmaPeriodInput from './SmaPeriodInput';
|
|
import {
|
|
SMA_DEFAULT_SRC,
|
|
SMA_MA_COUNT,
|
|
smaPeriodKey,
|
|
smaPlotId,
|
|
} 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<IndicatorSettingsFormProps> = ({
|
|
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<IndicatorSettingsEditorSnapshot>) => {
|
|
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<HLineDef>) =>
|
|
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<string, number | string | boolean>;
|
|
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<PlotDef>) => {
|
|
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) => {
|
|
patch({ params: { ...params, [smaPeriodKey(maIndex)]: v } });
|
|
}, [params, 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 (
|
|
<div className={sectionClass}>
|
|
{variant === 'embedded' && (
|
|
<div className="ibsm-form-toolbar">
|
|
<button type="button" className="ibsm-form-defaults" onClick={handleDefaults}>
|
|
기본값
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isSma && (
|
|
<div className="sma-inputs-section">
|
|
<GlobalParamRow
|
|
indicatorType="SMA"
|
|
paramKey="src"
|
|
value={String(params.src ?? SMA_DEFAULT_SRC)}
|
|
onChange={(_, raw) => patch({ params: { ...params, src: raw as string } })}
|
|
/>
|
|
<div className="ism-sma-divider" />
|
|
{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 (
|
|
<div key={plotId} className={`ism-row ism-sma-ma-row${enabled ? '' : ' ism-sma-ma-off'}`}>
|
|
<label className="ism-toggle ism-sma-ma-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
onChange={e => handleSmaToggle(i, e.target.checked)}
|
|
/>
|
|
<span className="ism-toggle-slider" />
|
|
</label>
|
|
<label className="ism-label ism-sma-ma-label">{getPlotLabel(`MA${maNum}`)}</label>
|
|
<div
|
|
className="ism-control ism-sma-ma-control"
|
|
onMouseDown={e => e.stopPropagation()}
|
|
onPointerDown={e => e.stopPropagation()}
|
|
>
|
|
<SmaPeriodInput
|
|
value={Number(params[periodKey] ?? 1)}
|
|
disabled={!enabled}
|
|
onCommit={v => handleSmaPeriod(maNum, v)}
|
|
/>
|
|
{plot && !inputsOnly && (
|
|
<PlotLineStylePicker
|
|
disabled={!enabled}
|
|
title={`MA${maNum}`}
|
|
value={{
|
|
color: plot.color,
|
|
lineWidth: plot.lineWidth,
|
|
lineStyle: plot.lineStyle,
|
|
}}
|
|
onChange={p => handlePlotStyle(i, p)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{!inputsOnly && <SettingsOutputFooter {...footerProps} />}
|
|
</div>
|
|
)}
|
|
|
|
{isIchimoku && (
|
|
<UnifiedIndicatorBody
|
|
config={config}
|
|
params={params}
|
|
plots={plots}
|
|
plotVis={plotVis}
|
|
hlines={[]}
|
|
hlinesBg={hlinesBg}
|
|
hasHLines={false}
|
|
lastValueVisible={lastValueVisible}
|
|
timeframeVis={timeframeVis}
|
|
timeframes={ALL_TIMEFRAMES}
|
|
onParamChange={handleParamChange}
|
|
onPlotToggle={handlePlotToggle}
|
|
onPlotStyle={handlePlotStyle}
|
|
onHLineVisible={handleHLineVisible}
|
|
onHLinePrice={handleHLinePrice}
|
|
onHLineStyle={handleHLinePlotStyle}
|
|
onHlinesBg={v => patch({ hlinesBg: v })}
|
|
onLastValueVisible={v => patch({ lastValueVisible: v })}
|
|
onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })}
|
|
chartHidden={chartHidden}
|
|
onChartHidden={onChartHiddenChange}
|
|
cloudSection={inputsOnly ? undefined : (
|
|
<IchimokuCloudSection
|
|
cloudColors={cloudColors}
|
|
onChange={p => patch({ cloudColors: { ...cloudColors, ...p } })}
|
|
/>
|
|
)}
|
|
inputsOnly={inputsOnly}
|
|
showOutputFooter={!inputsOnly}
|
|
/>
|
|
)}
|
|
|
|
{!isSma && !isIchimoku && (
|
|
<UnifiedIndicatorBody
|
|
config={config}
|
|
params={params}
|
|
plots={plots}
|
|
plotVis={plotVis}
|
|
hlines={hlines}
|
|
hlinesBg={hlinesBg}
|
|
hasHLines={hasHLines}
|
|
lastValueVisible={lastValueVisible}
|
|
timeframeVis={timeframeVis}
|
|
timeframes={ALL_TIMEFRAMES}
|
|
onParamChange={handleParamChange}
|
|
onPlotToggle={handlePlotToggle}
|
|
onPlotStyle={handlePlotStyle}
|
|
onHLineVisible={handleHLineVisible}
|
|
onHLinePrice={handleHLinePrice}
|
|
onHLineStyle={handleHLinePlotStyle}
|
|
onHlinesBg={v => 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 ? (
|
|
<BollingerSymbolSection
|
|
symbolMode={String(params.symbolMode ?? 'main')}
|
|
refSymbol={String(params.refSymbol ?? '')}
|
|
chartMarket={chartMarket}
|
|
onChange={p => {
|
|
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 ? (
|
|
<BollingerBackgroundRow value={bandBg} onChange={v => patch({ bandBg: v })} />
|
|
) : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default IndicatorSettingsForm;
|