실시간 차트 갱신시 화면떨림 문제 해결
This commit is contained in:
+45
-2
@@ -30,9 +30,11 @@ import { enrichIndicatorConfig, getIndicatorDef } from './utils/indicatorRegistr
|
|||||||
import { initializeIndicatorConfigForEditor } from './utils/indicatorSettingsEditor';
|
import { initializeIndicatorConfigForEditor } from './utils/indicatorSettingsEditor';
|
||||||
import {
|
import {
|
||||||
buildMainIndicatorConfig,
|
buildMainIndicatorConfig,
|
||||||
|
chartIndicatorsNeedDefaultsRefresh,
|
||||||
indicatorSettingsTemplateId,
|
indicatorSettingsTemplateId,
|
||||||
indicatorTypeFromSettingsId,
|
indicatorTypeFromSettingsId,
|
||||||
isIndicatorSettingsTemplateId,
|
isIndicatorSettingsTemplateId,
|
||||||
|
mergeGlobalDefaultsOntoChartIndicators,
|
||||||
} from './utils/indicatorMainConfig';
|
} from './utils/indicatorMainConfig';
|
||||||
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig';
|
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig';
|
||||||
import { normalizeIchimokuConfig } from './utils/ichimokuConfig';
|
import { normalizeIchimokuConfig } from './utils/ichimokuConfig';
|
||||||
@@ -49,6 +51,8 @@ import {
|
|||||||
duplicateIndicatorInList,
|
duplicateIndicatorInList,
|
||||||
mergeIndicators,
|
mergeIndicators,
|
||||||
reorderIndicatorGroup,
|
reorderIndicatorGroup,
|
||||||
|
removeIndicatorFromList,
|
||||||
|
removeIndicatorTypeFromList,
|
||||||
splitIndicatorPane,
|
splitIndicatorPane,
|
||||||
replaceIndicatorConfigsFromTypes,
|
replaceIndicatorConfigsFromTypes,
|
||||||
} from './utils/indicatorPaneMerge';
|
} from './utils/indicatorPaneMerge';
|
||||||
@@ -199,6 +203,7 @@ function App() {
|
|||||||
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
||||||
const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade');
|
const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade');
|
||||||
const [menuPage, setMenuPage] = useState<MenuPage>('chart');
|
const [menuPage, setMenuPage] = useState<MenuPage>('chart');
|
||||||
|
const chartVisible = menuPage === 'chart';
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// ── 마켓 패널 시세 데이터 ─────────────────────────────────────────────
|
// ── 마켓 패널 시세 데이터 ─────────────────────────────────────────────
|
||||||
@@ -1393,7 +1398,7 @@ function App() {
|
|||||||
const slotRef = slotRefs.current[activeSlot];
|
const slotRef = slotRefs.current[activeSlot];
|
||||||
if (slotRef) { slotRef.removeIndicatorByType(type); return; }
|
if (slotRef) { slotRef.removeIndicatorByType(type); return; }
|
||||||
}
|
}
|
||||||
setIndicators(prev => prev.filter(i => i.type !== type));
|
setIndicators(prev => removeIndicatorTypeFromList(prev, type));
|
||||||
}, [layoutDef.count, activeSlot]);
|
}, [layoutDef.count, activeSlot]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1480,6 +1485,41 @@ function App() {
|
|||||||
|
|
||||||
const bulkSettingsIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators;
|
const bulkSettingsIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators;
|
||||||
|
|
||||||
|
/** 설정 화면에서 DB 기본값 저장 후 차트 복귀 시 — 전역 기본값을 차트 인스턴스에 일괄 반영 */
|
||||||
|
const lastSyncedSettingsRevisionRef = useRef(indicatorSettingsRevision);
|
||||||
|
const syncChartsFromSavedDefaults = useCallback(() => {
|
||||||
|
const merge = (inds: IndicatorConfig[]) =>
|
||||||
|
mergeGlobalDefaultsOntoChartIndicators(inds, getParams, getVisualConfig);
|
||||||
|
|
||||||
|
if (layoutDef.count > 1) {
|
||||||
|
slotRefs.current.slice(0, layoutDef.count).forEach(slot => {
|
||||||
|
if (!slot) return;
|
||||||
|
const current = slot.getIndicators();
|
||||||
|
const next = merge(current);
|
||||||
|
if (chartIndicatorsNeedDefaultsRefresh(current, next)) {
|
||||||
|
slot.setIndicators(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const activeInds = slotRefs.current[activeSlot]?.getIndicators() ?? activeSlotIndicators;
|
||||||
|
const mergedActive = merge(activeInds);
|
||||||
|
if (chartIndicatorsNeedDefaultsRefresh(activeInds, mergedActive)) {
|
||||||
|
setActiveSlotIndicators(mergedActive.map(i => ({ ...i })));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIndicators(prev => {
|
||||||
|
const next = merge(prev);
|
||||||
|
return chartIndicatorsNeedDefaultsRefresh(prev, next) ? next : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [layoutDef.count, activeSlot, activeSlotIndicators, getParams, getVisualConfig]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!chartVisible) return;
|
||||||
|
if (indicatorSettingsRevision <= lastSyncedSettingsRevisionRef.current) return;
|
||||||
|
syncChartsFromSavedDefaults();
|
||||||
|
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
||||||
|
}, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
|
||||||
|
|
||||||
/** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */
|
/** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */
|
||||||
const handleBulkIndicatorApply = useCallback((result: {
|
const handleBulkIndicatorApply = useCallback((result: {
|
||||||
chartIndicators: IndicatorConfig[];
|
chartIndicators: IndicatorConfig[];
|
||||||
@@ -1511,7 +1551,7 @@ function App() {
|
|||||||
}, [layoutDef.count, activeSlot]);
|
}, [layoutDef.count, activeSlot]);
|
||||||
|
|
||||||
const handleRemoveIndicatorById = useCallback((id: string) => {
|
const handleRemoveIndicatorById = useCallback((id: string) => {
|
||||||
setIndicators(prev => prev.filter(i => i.id !== id));
|
setIndicators(prev => removeIndicatorFromList(prev, id));
|
||||||
setFocusedIndicatorId(prev => prev === id ? null : prev);
|
setFocusedIndicatorId(prev => prev === id ? null : prev);
|
||||||
setCtxToolbar(null);
|
setCtxToolbar(null);
|
||||||
setSettingsModalId(null);
|
setSettingsModalId(null);
|
||||||
@@ -2038,6 +2078,7 @@ function App() {
|
|||||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
compactMode
|
compactMode
|
||||||
|
chartVisible={chartVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 슬롯 1~7 */}
|
{/* 슬롯 1~7 */}
|
||||||
@@ -2075,6 +2116,7 @@ function App() {
|
|||||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
compactMode
|
compactMode
|
||||||
|
chartVisible={chartVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -2114,6 +2156,7 @@ function App() {
|
|||||||
|
|
||||||
<TradingChart
|
<TradingChart
|
||||||
key={chartMountKey}
|
key={chartMountKey}
|
||||||
|
chartVisible={chartVisible}
|
||||||
bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
|
bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
|
||||||
chartType={chartType} theme={theme} mode={mode}
|
chartType={chartType} theme={theme} mode={mode}
|
||||||
indicators={effectiveIndicators} drawingTool={drawingTool}
|
indicators={effectiveIndicators} drawingTool={drawingTool}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
duplicateIndicatorInList,
|
duplicateIndicatorInList,
|
||||||
mergeIndicators,
|
mergeIndicators,
|
||||||
reorderIndicatorGroup,
|
reorderIndicatorGroup,
|
||||||
|
removeIndicatorFromList,
|
||||||
|
removeIndicatorTypeFromList,
|
||||||
splitIndicatorPane,
|
splitIndicatorPane,
|
||||||
replaceIndicatorConfigsFromTypes,
|
replaceIndicatorConfigsFromTypes,
|
||||||
} from '../utils/indicatorPaneMerge';
|
} from '../utils/indicatorPaneMerge';
|
||||||
@@ -168,6 +170,8 @@ export interface ChartSlotProps {
|
|||||||
compactMode?: boolean;
|
compactMode?: boolean;
|
||||||
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */
|
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */
|
||||||
chartLiveReceiveHighlight?: boolean;
|
chartLiveReceiveHighlight?: boolean;
|
||||||
|
/** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */
|
||||||
|
chartVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||||
@@ -190,6 +194,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
displayTimezone,
|
displayTimezone,
|
||||||
compactMode = false,
|
compactMode = false,
|
||||||
chartLiveReceiveHighlight = true,
|
chartLiveReceiveHighlight = true,
|
||||||
|
chartVisible = true,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||||
@@ -278,7 +283,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
removeIndicatorByType: (type: string) => {
|
removeIndicatorByType: (type: string) => {
|
||||||
setIndicators(prev => prev.filter(i => i.type !== type));
|
setIndicators(prev => removeIndicatorTypeFromList(prev, type));
|
||||||
},
|
},
|
||||||
duplicateIndicator: (sourceId: string) => {
|
duplicateIndicator: (sourceId: string) => {
|
||||||
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
|
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
|
||||||
@@ -556,7 +561,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRemoveById = useCallback((id: string) => {
|
const handleRemoveById = useCallback((id: string) => {
|
||||||
setIndicators(prev => prev.filter(i => i.id !== id));
|
setIndicators(prev => removeIndicatorFromList(prev, id));
|
||||||
setCtxToolbar(null);
|
setCtxToolbar(null);
|
||||||
setSettingsModalId(null);
|
setSettingsModalId(null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -588,7 +593,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRemoveByIdSlot = useCallback((id: string) => {
|
const handleRemoveByIdSlot = useCallback((id: string) => {
|
||||||
setIndicators(prev => prev.filter(i => i.id !== id));
|
setIndicators(prev => removeIndicatorFromList(prev, id));
|
||||||
setFocusedIndicatorId(prev => prev === id ? null : prev);
|
setFocusedIndicatorId(prev => prev === id ? null : prev);
|
||||||
setCtxToolbar(null);
|
setCtxToolbar(null);
|
||||||
setSettingsModalId(null);
|
setSettingsModalId(null);
|
||||||
@@ -784,6 +789,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
|
|
||||||
<TradingChart
|
<TradingChart
|
||||||
key={reloadTick}
|
key={reloadTick}
|
||||||
|
chartVisible={chartVisible}
|
||||||
bars={bars}
|
bars={bars}
|
||||||
barsMarket={barsMarket}
|
barsMarket={barsMarket}
|
||||||
market={symbol}
|
market={symbol}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface IndicatorModalProps {
|
|||||||
activeIndicators: IndicatorConfig[];
|
activeIndicators: IndicatorConfig[];
|
||||||
onAdd: (def: IndicatorDef, params: Record<string, number | string | boolean>) => void;
|
onAdd: (def: IndicatorDef, params: Record<string, number | string | boolean>) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
/** type 단위 일괄 제거 (여러 인스턴스를 한 번에 제거) */
|
||||||
|
onRemoveByType?: (type: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +19,9 @@ const CATEGORIES: IndicatorCategory[] = [
|
|||||||
'Trend', 'Volatility', 'Volume', 'Candlestick Patterns',
|
'Trend', 'Volatility', 'Volume', 'Candlestick Patterns',
|
||||||
];
|
];
|
||||||
|
|
||||||
const IndicatorModal: React.FC<IndicatorModalProps> = ({ activeIndicators, onAdd, onRemove, onClose }) => {
|
const IndicatorModal: React.FC<IndicatorModalProps> = ({
|
||||||
|
activeIndicators, onAdd, onRemove, onRemoveByType, onClose,
|
||||||
|
}) => {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [category, setCategory] = useState<IndicatorCategory | 'All'>('All');
|
const [category, setCategory] = useState<IndicatorCategory | 'All'>('All');
|
||||||
const [selected, setSelected] = useState<IndicatorDef | null>(null);
|
const [selected, setSelected] = useState<IndicatorDef | null>(null);
|
||||||
@@ -45,6 +49,10 @@ const IndicatorModal: React.FC<IndicatorModalProps> = ({ activeIndicators, onAdd
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveAll = (type: string) => {
|
const handleRemoveAll = (type: string) => {
|
||||||
|
if (onRemoveByType) {
|
||||||
|
onRemoveByType(type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
activeIndicators.filter(a => a.type === type).forEach(a => onRemove(a.id));
|
activeIndicators.filter(a => a.type === type).forEach(a => onRemove(a.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
|||||||
/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */
|
/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */
|
||||||
const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4));
|
const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4));
|
||||||
|
|
||||||
|
function createIndicatorReloadCover(containerEl: HTMLDivElement | null): HTMLDivElement | null {
|
||||||
|
if (!containerEl) return null;
|
||||||
|
const cover = document.createElement('div');
|
||||||
|
cover.style.cssText = [
|
||||||
|
'position:absolute', 'inset:0', 'z-index:900',
|
||||||
|
'pointer-events:none', 'opacity:1',
|
||||||
|
'background:var(--bg,#131722)',
|
||||||
|
].join(';');
|
||||||
|
containerEl.parentElement?.appendChild(cover);
|
||||||
|
return cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fadeOutIndicatorReloadCover(cover: HTMLDivElement | null): void {
|
||||||
|
if (!cover) return;
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
cover.style.transition = 'opacity 0.25s ease';
|
||||||
|
cover.style.opacity = '0';
|
||||||
|
setTimeout(() => { cover.remove(); }, 300);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** 종목 전환 직후 stale/partial 데이터 차단 — barsMarket 확정 후 렌더 허용 */
|
/** 종목 전환 직후 stale/partial 데이터 차단 — barsMarket 확정 후 렌더 허용 */
|
||||||
function canApplyChartBars(
|
function canApplyChartBars(
|
||||||
barData: OHLCVBar[],
|
barData: OHLCVBar[],
|
||||||
@@ -29,6 +50,7 @@ import ChartContextMenu from './ChartContextMenu';
|
|||||||
import CandlePaneControls from './CandlePaneControls';
|
import CandlePaneControls from './CandlePaneControls';
|
||||||
import { getKoreanName } from '../utils/marketNameCache';
|
import { getKoreanName } from '../utils/marketNameCache';
|
||||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
|
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
|
||||||
|
import { classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
|
||||||
|
|
||||||
interface TradingChartProps {
|
interface TradingChartProps {
|
||||||
bars: OHLCVBar[];
|
bars: OHLCVBar[];
|
||||||
@@ -102,6 +124,8 @@ interface TradingChartProps {
|
|||||||
showHoverToolbar?: boolean;
|
showHoverToolbar?: boolean;
|
||||||
/** 보조지표 우측 가격축 라벨·설명 (차트 설정, 기본 true) */
|
/** 보조지표 우측 가격축 라벨·설명 (차트 설정, 기본 true) */
|
||||||
seriesPriceLabelsEnabled?: boolean;
|
seriesPriceLabelsEnabled?: boolean;
|
||||||
|
/** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */
|
||||||
|
chartVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TradingChart: React.FC<TradingChartProps> = ({
|
const TradingChart: React.FC<TradingChartProps> = ({
|
||||||
@@ -131,6 +155,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
volumeVisible = true,
|
volumeVisible = true,
|
||||||
showHoverToolbar = true,
|
showHoverToolbar = true,
|
||||||
seriesPriceLabelsEnabled = true,
|
seriesPriceLabelsEnabled = true,
|
||||||
|
chartVisible = true,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||||
@@ -180,6 +205,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const prevBarsKey = useRef<string>('');
|
const prevBarsKey = useRef<string>('');
|
||||||
const prevIndKey = useRef<string>('');
|
const prevIndKey = useRef<string>('');
|
||||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||||
|
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
||||||
|
const indicatorSyncInFlightRef = useRef(false);
|
||||||
const prevMarket = useRef<string>(market);
|
const prevMarket = useRef<string>(market);
|
||||||
const prevMarketTf = useRef<Timeframe>(timeframe);
|
const prevMarketTf = useRef<Timeframe>(timeframe);
|
||||||
const lastAppliedMarketRef = useRef<string>('');
|
const lastAppliedMarketRef = useRef<string>('');
|
||||||
@@ -188,6 +215,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const prevLogScale = useRef<boolean>(logScale);
|
const prevLogScale = useRef<boolean>(logScale);
|
||||||
const indicatorsRef = useRef(indicators);
|
const indicatorsRef = useRef(indicators);
|
||||||
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const resizeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const chartVisibleRef = useRef(chartVisible);
|
||||||
|
|
||||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||||
@@ -205,6 +234,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
indicatorsRef.current = indicators;
|
indicatorsRef.current = indicators;
|
||||||
}, [indicators]);
|
}, [indicators]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chartVisibleRef.current = chartVisible;
|
||||||
|
}, [chartVisible]);
|
||||||
|
|
||||||
const toggleCandleOnly = useCallback(() => {
|
const toggleCandleOnly = useCallback(() => {
|
||||||
setCandleOnlyMode(v => !v);
|
setCandleOnlyMode(v => !v);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -326,7 +359,71 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
inds: IndicatorConfig[],
|
inds: IndicatorConfig[],
|
||||||
) => Promise<void>>(async () => {});
|
) => Promise<void>>(async () => {});
|
||||||
|
|
||||||
|
const syncIndicatorTrackingRefs = useCallback((inds: IndicatorConfig[]) => {
|
||||||
|
prevIndKey.current = indKey(inds);
|
||||||
|
prevSortedPKRef.current = sortedParamKey(inds);
|
||||||
|
prevIndicatorsListRef.current = inds;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreLogicalRange = useCallback((
|
||||||
|
mgr: ChartManager,
|
||||||
|
savedLR: { from: number; to: number } | null,
|
||||||
|
) => {
|
||||||
|
if (!savedLR) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const afterIndicatorPaneMutation = useCallback((mgr: ChartManager) => {
|
||||||
|
if (candleOnlyModeRef.current) {
|
||||||
|
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
||||||
|
} else if (mgr.isCandleOnlyLayout()) {
|
||||||
|
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
|
||||||
|
}
|
||||||
|
applyPaneLayout(mgr);
|
||||||
|
}, [applyPaneLayout]);
|
||||||
|
|
||||||
|
/** 누락된 지표만 추가 (전체 reloadIndicatorsOnly 회피) */
|
||||||
|
const repairMissingIndicators = useCallback(async (
|
||||||
|
mgr: ChartManager,
|
||||||
|
inds: IndicatorConfig[],
|
||||||
|
) => {
|
||||||
|
const loadedIds = mgr.getLoadedIndicatorIds();
|
||||||
|
const missing = inds.filter(i => i.hidden !== true && !loadedIds.has(i.id));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
indicatorSyncInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
await mgr.addIndicatorsBatch(missing);
|
||||||
|
afterIndicatorPaneMutation(mgr);
|
||||||
|
} finally {
|
||||||
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [afterIndicatorPaneMutation]);
|
||||||
|
|
||||||
|
/** 지표만 재로드 — 커버 + 스크롤 위치 복원으로 깜빡임·떨림 완화 */
|
||||||
|
const reloadIndicatorsWithCover = useCallback((
|
||||||
|
mgr: ChartManager,
|
||||||
|
inds: IndicatorConfig[],
|
||||||
|
onComplete?: () => void,
|
||||||
|
) => {
|
||||||
|
const cover = createIndicatorReloadCover(containerRef.current);
|
||||||
|
const savedLR = mgr.getVisibleLogicalRange();
|
||||||
|
|
||||||
|
indicatorSyncInFlightRef.current = true;
|
||||||
|
void mgr.reloadIndicatorsOnly(inds).then(() => {
|
||||||
|
afterIndicatorPaneMutation(mgr);
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
restoreLogicalRange(mgr, savedLR);
|
||||||
|
fadeOutIndicatorReloadCover(cover);
|
||||||
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
onComplete?.();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [applyPaneLayout, afterIndicatorPaneMutation, restoreLogicalRange]);
|
||||||
|
|
||||||
const queueFullReload = useCallback(() => {
|
const queueFullReload = useCallback(() => {
|
||||||
|
if (!chartVisibleRef.current) return;
|
||||||
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||||
recoveryTimerRef.current = window.setTimeout(() => {
|
recoveryTimerRef.current = window.setTimeout(() => {
|
||||||
recoveryTimerRef.current = null;
|
recoveryTimerRef.current = null;
|
||||||
@@ -345,6 +442,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
}, [market, barsMarket]);
|
}, [market, barsMarket]);
|
||||||
|
|
||||||
const queueIndicatorRecovery = useCallback(() => {
|
const queueIndicatorRecovery = useCallback(() => {
|
||||||
|
if (!chartVisibleRef.current) return;
|
||||||
if (reloadInFlightRef.current) return;
|
if (reloadInFlightRef.current) return;
|
||||||
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||||
recoveryTimerRef.current = window.setTimeout(() => {
|
recoveryTimerRef.current = window.setTimeout(() => {
|
||||||
@@ -365,10 +463,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const ls = prevLogScale.current;
|
const ls = prevLogScale.current;
|
||||||
|
|
||||||
if (m.hasMainSeries()) {
|
if (m.hasMainSeries()) {
|
||||||
void m.reloadIndicatorsOnly(inds).then(() => {
|
void repairMissingIndicators(m, inds).then(() => {
|
||||||
if (m.getLoadedIndicatorCount() >= expected) {
|
if (m.getLoadedIndicatorCount() >= expected) {
|
||||||
prevBarsKey.current = barsKey(barData, market);
|
prevBarsKey.current = barsKey(barData, market);
|
||||||
prevIndKey.current = indKey(inds);
|
syncIndicatorTrackingRefs(inds);
|
||||||
applyPaneLayout(m);
|
applyPaneLayout(m);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -378,7 +476,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
}
|
}
|
||||||
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
|
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
|
||||||
}, 120);
|
}, 120);
|
||||||
}, [market, barsMarket, applyPaneLayout]);
|
}, [market, barsMarket, applyPaneLayout, repairMissingIndicators, syncIndicatorTrackingRefs]);
|
||||||
|
|
||||||
/** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */
|
/** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */
|
||||||
const commitCandleLayout = useCallback((mgr: ChartManager) => {
|
const commitCandleLayout = useCallback((mgr: ChartManager) => {
|
||||||
@@ -474,8 +572,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
prevTheme.current = th;
|
prevTheme.current = th;
|
||||||
prevLogScale.current = ls;
|
prevLogScale.current = ls;
|
||||||
prevBarsKey.current = barsKey(barData, market);
|
prevBarsKey.current = barsKey(barData, market);
|
||||||
prevIndKey.current = indKey(inds);
|
syncIndicatorTrackingRefs(inds);
|
||||||
prevSortedPKRef.current = sortedParamKey(inds);
|
|
||||||
lastAppliedMarketRef.current = market;
|
lastAppliedMarketRef.current = market;
|
||||||
|
|
||||||
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
|
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
@@ -768,6 +865,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
prevW = width; prevH = height;
|
prevW = width; prevH = height;
|
||||||
if (width <= 0 || height <= 0) return;
|
if (width <= 0 || height <= 0) return;
|
||||||
|
|
||||||
|
if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
|
||||||
|
resizeDebounceRef.current = window.setTimeout(() => {
|
||||||
|
resizeDebounceRef.current = null;
|
||||||
const m = managerRef.current;
|
const m = managerRef.current;
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
|
|
||||||
@@ -784,17 +884,23 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
prevLogScale.current,
|
prevLogScale.current,
|
||||||
indicatorsRef.current,
|
indicatorsRef.current,
|
||||||
);
|
);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedLR = wasHidden && m.hasMainSeries() ? m.getVisibleLogicalRange() : null;
|
||||||
applyPaneLayout(m);
|
applyPaneLayout(m);
|
||||||
// 멀티→단일 레이아웃 전환처럼 숨겨졌다가 다시 표시될 때
|
// 숨김→표시 전환: 줌/스크롤 위치 유지 (setInitialVisibleRange 로 초기화하면 화면이 튐)
|
||||||
// setInitialVisibleRange 를 재실행해 가시 범위(캔들 분포)를 정상화
|
|
||||||
if (wasHidden && m.hasMainSeries()) {
|
if (wasHidden && m.hasMainSeries()) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (savedLR) {
|
||||||
|
m.applyVisibleLogicalRange(savedLR.from, savedLR.to);
|
||||||
|
} else {
|
||||||
const fb = m.hasIchimoku() ? 28 : 0;
|
const fb = m.hasIchimoku() ? 28 : 0;
|
||||||
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}, 80);
|
||||||
});
|
});
|
||||||
ro.observe(containerRef.current);
|
ro.observe(containerRef.current);
|
||||||
|
|
||||||
@@ -832,6 +938,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||||
reloadSafetyTimers.current = [];
|
reloadSafetyTimers.current = [];
|
||||||
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||||
|
if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
managerRef.current?.destroy();
|
managerRef.current?.destroy();
|
||||||
managerRef.current = null;
|
managerRef.current = null;
|
||||||
@@ -851,6 +958,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
prevMarketTf.current = timeframe;
|
prevMarketTf.current = timeframe;
|
||||||
prevBarsKey.current = '';
|
prevBarsKey.current = '';
|
||||||
prevIndKey.current = '';
|
prevIndKey.current = '';
|
||||||
|
prevIndicatorsListRef.current = [];
|
||||||
lastAppliedMarketRef.current = '';
|
lastAppliedMarketRef.current = '';
|
||||||
barsRef.current = [];
|
barsRef.current = [];
|
||||||
reloadRetryRef.current = 0;
|
reloadRetryRef.current = 0;
|
||||||
@@ -869,6 +977,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
if (!mgr || !chartMgr || bars.length < 2) return;
|
if (!mgr || !chartMgr || bars.length < 2) return;
|
||||||
if (!canApplyChartBars(bars, barsMarket, market)) return;
|
if (!canApplyChartBars(bars, barsMarket, market)) return;
|
||||||
|
|
||||||
|
if (!chartVisible) {
|
||||||
|
// 설정 등 다른 화면에서 숨김 중 — 키만 동기화해 복귀 시 불필요한 전체 재로드 방지
|
||||||
|
prevBarsKey.current = barsKey(bars, market);
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
prevChartType.current = chartType;
|
||||||
|
prevTheme.current = theme;
|
||||||
|
prevLogScale.current = logScale;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// manager 준비 전·종목 미적용 시 reloadAll 강제
|
// manager 준비 전·종목 미적용 시 reloadAll 강제
|
||||||
if (!mgr.hasMainSeries() || lastAppliedMarketRef.current !== market) {
|
if (!mgr.hasMainSeries() || lastAppliedMarketRef.current !== market) {
|
||||||
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
|
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
|
||||||
@@ -896,91 +1014,105 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (barsChanged || ctChanged || indicatorsIncomplete) {
|
if (barsChanged || ctChanged) {
|
||||||
// 데이터·차트 형식 변경 또는 지표 누락 → 전체 재로드
|
|
||||||
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
|
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
|
||||||
|
} else if (indicatorsIncomplete) {
|
||||||
|
// 누락 지표만 추가 (전체 제거·재추가로 화면이 비는 문제 방지)
|
||||||
|
void repairMissingIndicators(mgr, indicators).then(() => {
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
prevBarsKey.current = barsKey(bars, market);
|
||||||
|
});
|
||||||
} else if (lsChanged) {
|
} else if (lsChanged) {
|
||||||
prevLogScale.current = logScale;
|
prevLogScale.current = logScale;
|
||||||
mgr.setLogScale(logScale);
|
mgr.setLogScale(logScale);
|
||||||
} else if (indChanged) {
|
} else if (indChanged) {
|
||||||
|
const prevList = prevIndicatorsListRef.current;
|
||||||
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
|
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
|
||||||
const currPK = paramKey(indicators);
|
const currPK = paramKey(indicators);
|
||||||
const prevSK = prevIndKey.current.split('@@')[1] ?? '';
|
const prevSK = prevIndKey.current.split('@@')[1] ?? '';
|
||||||
const currSK = styleKey(indicators);
|
const currSK = styleKey(indicators);
|
||||||
|
|
||||||
const needsRecalc = prevPK !== currPK;
|
const needsRecalc = prevPK !== currPK;
|
||||||
// 같은 지표 집합인데 순서만 바뀐 경우 (reorder-only)
|
|
||||||
const isReorderOnly = needsRecalc
|
const isReorderOnly = needsRecalc
|
||||||
&& sortedParamKey(indicators) === prevSortedPKRef.current
|
&& sortedParamKey(indicators) === sortedParamKey(prevList)
|
||||||
&& currSK === prevSK;
|
&& currSK === prevSK;
|
||||||
|
|
||||||
if (isReorderOnly) {
|
const prevMap = new Map(prevList.map(i => [i.id, i]));
|
||||||
// ── pane 순서 변경만: 메인 차트를 건드리지 않고 지표만 재배치 ──────────
|
const removedIds = prevList
|
||||||
// 1) 지표 영역만 커버 (순간 숨김) → 2) 지표만 재추가 → 3) 서서히 공개
|
.filter(p => !indicators.some(c => c.id === p.id))
|
||||||
prevSortedPKRef.current = sortedParamKey(indicators);
|
.map(p => p.id);
|
||||||
prevIndKey.current = ik;
|
const addedConfigs = indicators.filter(c => !prevMap.has(c.id));
|
||||||
|
const paramsChangedConfigs = indicators.filter(c => {
|
||||||
const containerEl = containerRef.current;
|
const p = prevMap.get(c.id);
|
||||||
// 지표 영역 위에 불투명 커버 div 삽입
|
if (!p) return false;
|
||||||
let cover: HTMLDivElement | null = null;
|
return singleIndParamKey(p) !== singleIndParamKey(c);
|
||||||
if (containerEl) {
|
|
||||||
cover = document.createElement('div');
|
|
||||||
cover.style.cssText = [
|
|
||||||
'position:absolute', 'inset:0', 'z-index:900',
|
|
||||||
'pointer-events:none', 'opacity:1',
|
|
||||||
'background:var(--bg,#131722)',
|
|
||||||
].join(';');
|
|
||||||
containerEl.parentElement?.appendChild(cover);
|
|
||||||
}
|
|
||||||
|
|
||||||
mgr.reloadIndicatorsOnly(indicators).then(() => {
|
|
||||||
if (candleOnlyModeRef.current) {
|
|
||||||
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
|
||||||
}
|
|
||||||
applyPaneLayout(mgr);
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
||||||
if (cover) {
|
|
||||||
cover.style.transition = 'opacity 0.25s ease';
|
|
||||||
cover.style.opacity = '0';
|
|
||||||
setTimeout(() => { cover?.remove(); }, 300);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
} else if (needsRecalc) {
|
|
||||||
// ── 지표 추가·제거·파라미터 변경 → 지표만 재로드 (메인 캔들 유지) ──
|
|
||||||
// reloadIndicatorsOnly 는 메인/볼륨 시리즈를 건드리지 않으므로
|
|
||||||
// 차트 위치 초기화·깜빡임 없이 지표만 갱신된다.
|
|
||||||
prevSortedPKRef.current = sortedParamKey(indicators);
|
|
||||||
prevIndKey.current = ik;
|
|
||||||
|
|
||||||
// 현재 scroll/zoom 위치를 저장해 reload 후 복원
|
if (!needsRecalc && prevSK !== currSK) {
|
||||||
const savedLR = mgr.getVisibleLogicalRange();
|
|
||||||
|
|
||||||
mgr.reloadIndicatorsOnly(indicators).then(() => {
|
|
||||||
if (candleOnlyModeRef.current) {
|
|
||||||
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
|
||||||
} else if (mgr.isCandleOnlyLayout()) {
|
|
||||||
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
|
|
||||||
}
|
|
||||||
applyPaneLayout(mgr);
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
||||||
if (savedLR) mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
} else if (prevSK !== currSK) {
|
|
||||||
// ── 스타일만 변경 (색상·선폭·plotVisibility) → in-place 업데이트 ────
|
|
||||||
// 시리즈를 제거·재생성하지 않으므로 pane 재번호 없음
|
|
||||||
for (const ind of indicators) {
|
for (const ind of indicators) {
|
||||||
mgr.applyIndicatorStyle(ind);
|
mgr.applyIndicatorStyle(ind);
|
||||||
}
|
}
|
||||||
prevIndKey.current = ik;
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
} else if (
|
||||||
|
removedIds.length > 0
|
||||||
|
&& addedConfigs.length === 0
|
||||||
|
&& paramsChangedConfigs.length === 0
|
||||||
|
) {
|
||||||
|
const savedLR = mgr.getVisibleLogicalRange();
|
||||||
|
indicatorSyncInFlightRef.current = true;
|
||||||
|
mgr.removeIndicators(removedIds);
|
||||||
|
afterIndicatorPaneMutation(mgr);
|
||||||
|
restoreLogicalRange(mgr, savedLR);
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
void repairMissingIndicators(mgr, indicators);
|
||||||
|
} else if (
|
||||||
|
addedConfigs.length > 0
|
||||||
|
&& removedIds.length === 0
|
||||||
|
&& paramsChangedConfigs.length === 0
|
||||||
|
) {
|
||||||
|
const savedLR = mgr.getVisibleLogicalRange();
|
||||||
|
indicatorSyncInFlightRef.current = true;
|
||||||
|
void mgr.addIndicatorsBatch(addedConfigs).then(() => {
|
||||||
|
afterIndicatorPaneMutation(mgr);
|
||||||
|
restoreLogicalRange(mgr, savedLR);
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
paramsChangedConfigs.length > 0
|
||||||
|
&& removedIds.length === 0
|
||||||
|
&& addedConfigs.length === 0
|
||||||
|
) {
|
||||||
|
const savedLR = mgr.getVisibleLogicalRange();
|
||||||
|
indicatorSyncInFlightRef.current = true;
|
||||||
|
void mgr.refreshIndicators(paramsChangedConfigs).then(() => {
|
||||||
|
afterIndicatorPaneMutation(mgr);
|
||||||
|
restoreLogicalRange(mgr, savedLR);
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
});
|
||||||
|
} else if (isReorderOnly) {
|
||||||
|
reloadIndicatorsWithCover(mgr, indicators, () => {
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const change = classifyIndicatorChartChange(prevList, indicators);
|
||||||
|
if (change.mode === 'full') {
|
||||||
|
reloadIndicatorsWithCover(mgr, indicators, () => {
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]);
|
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators]);
|
||||||
|
|
||||||
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
|
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!chartVisible) return;
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
if (!mgr || !chartMgr || bars.length < 2) return;
|
if (!mgr || !chartMgr || bars.length < 2) return;
|
||||||
if (!canApplyChartBars(bars, barsMarket, market)) return;
|
if (!canApplyChartBars(bars, barsMarket, market)) return;
|
||||||
@@ -1004,10 +1136,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => { timers.forEach(clearTimeout); };
|
return () => { timers.forEach(clearTimeout); };
|
||||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]);
|
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible]);
|
||||||
|
|
||||||
// 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구
|
// 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!chartVisible) return;
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
if (!mgr || !chartMgr || bars.length < 2) return;
|
if (!mgr || !chartMgr || bars.length < 2) return;
|
||||||
if (!canApplyChartBars(bars, barsMarket, market)) return;
|
if (!canApplyChartBars(bars, barsMarket, market)) return;
|
||||||
@@ -1018,19 +1151,22 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
|
|
||||||
const timers = [120, 400, 900].map(delay =>
|
const timers = [120, 400, 900].map(delay =>
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (reloadInFlightRef.current) return;
|
if (reloadInFlightRef.current || indicatorSyncInFlightRef.current) return;
|
||||||
const m = managerRef.current;
|
const m = managerRef.current;
|
||||||
if (!m || !m.hasMainSeries()) return;
|
if (!m || !m.hasMainSeries()) return;
|
||||||
if (!canApplyChartBars(bars, barsMarket, market)) return;
|
if (!canApplyChartBars(bars, barsMarket, market)) return;
|
||||||
const exp = countExpectedVisibleIndicators(indicatorsRef.current);
|
const exp = countExpectedVisibleIndicators(indicatorsRef.current);
|
||||||
if (exp === 0 || m.getLoadedIndicatorCount() >= exp) return;
|
if (exp === 0 || m.getLoadedIndicatorCount() >= exp) return;
|
||||||
|
void repairMissingIndicators(m, indicatorsRef.current).then(() => {
|
||||||
|
if (m.getLoadedIndicatorCount() >= exp) return;
|
||||||
prevBarsKey.current = '';
|
prevBarsKey.current = '';
|
||||||
queueIndicatorRecovery();
|
queueIndicatorRecovery();
|
||||||
|
});
|
||||||
}, delay),
|
}, delay),
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => { timers.forEach(clearTimeout); };
|
return () => { timers.forEach(clearTimeout); };
|
||||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, queueIndicatorRecovery]);
|
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, queueIndicatorRecovery, repairMissingIndicators]);
|
||||||
|
|
||||||
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
|
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
|
||||||
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
|
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
|||||||
@@ -532,7 +532,10 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Indicators ─────────────────────────────────────────────────────────
|
// ─── Indicators ─────────────────────────────────────────────────────────
|
||||||
async addIndicator(config: IndicatorConfig): Promise<void> {
|
async addIndicator(
|
||||||
|
config: IndicatorConfig,
|
||||||
|
options?: { skipLayout?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
config = enrichIndicatorConfig(config);
|
config = enrichIndicatorConfig(config);
|
||||||
if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
|
if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
|
||||||
const dataGenAtStart = this._dataGeneration;
|
const dataGenAtStart = this._dataGeneration;
|
||||||
@@ -664,12 +667,50 @@ export class ChartManager {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Indicator] ${config.type} error:`, e);
|
console.error(`[Indicator] ${config.type} error:`, e);
|
||||||
} finally {
|
} finally {
|
||||||
if (this._activeIndicatorPaneIndices().size > 0) {
|
if (
|
||||||
|
!options?.skipLayout
|
||||||
|
&& this._activeIndicatorPaneIndices().size > 0
|
||||||
|
) {
|
||||||
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 여러 지표 추가 후 pane 레이아웃을 한 번만 갱신 */
|
||||||
|
async addIndicatorsBatch(configs: IndicatorConfig[]): Promise<void> {
|
||||||
|
for (const config of configs) {
|
||||||
|
await this.addIndicator(config, { skipLayout: true });
|
||||||
|
}
|
||||||
|
if (configs.length > 0 && this._activeIndicatorPaneIndices().size > 0) {
|
||||||
|
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
||||||
|
this._notifyPaneLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _detachIndicatorEntry(id: string): boolean {
|
||||||
|
const entry = this.indicators.get(id);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (entry.seriesList.length > 0) {
|
||||||
|
for (const pl of entry.hlineRefs) {
|
||||||
|
try { entry.seriesList[0].removePriceLine(pl); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
if (entry.fillPrimitive) {
|
||||||
|
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.cloudPlugin) {
|
||||||
|
for (const s of entry.seriesList) {
|
||||||
|
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detachBbBandFill(entry);
|
||||||
|
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
||||||
|
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
|
||||||
|
this._reapplyAllPatternMarkers();
|
||||||
|
this.indicators.delete(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 순서 변경 전용 재로드 — 메인 차트·볼륨 시리즈는 유지하고 보조지표만 제거 후 재추가.
|
* 순서 변경 전용 재로드 — 메인 차트·볼륨 시리즈는 유지하고 보조지표만 제거 후 재추가.
|
||||||
* reloadAll 과 달리 setData()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다.
|
* reloadAll 과 달리 setData()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다.
|
||||||
@@ -708,31 +749,32 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeIndicator(id: string): void {
|
removeIndicator(id: string): void {
|
||||||
const entry = this.indicators.get(id);
|
this.removeIndicators([id]);
|
||||||
if (!entry) return;
|
|
||||||
// hlines price line 및 fill primitive 정리
|
|
||||||
if (entry.seriesList.length > 0) {
|
|
||||||
for (const pl of entry.hlineRefs) {
|
|
||||||
try { entry.seriesList[0].removePriceLine(pl); } catch { /* ok */ }
|
|
||||||
}
|
}
|
||||||
if (entry.fillPrimitive) {
|
|
||||||
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
|
/** 여러 지표 제거 후 pane 레이아웃을 한 번만 갱신 (줌·스크롤 유지에 유리) */
|
||||||
|
removeIndicators(ids: string[]): void {
|
||||||
|
let any = false;
|
||||||
|
for (const id of ids) {
|
||||||
|
if (this._detachIndicatorEntry(id)) any = true;
|
||||||
}
|
}
|
||||||
}
|
if (!any) return;
|
||||||
if (entry.cloudPlugin) {
|
this._trimTrailingEmptySubPanes();
|
||||||
for (const s of entry.seriesList) {
|
|
||||||
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
|
||||||
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
|
|
||||||
this._reapplyAllPatternMarkers();
|
|
||||||
this.indicators.delete(id);
|
|
||||||
this._removeExtraSubPanes();
|
|
||||||
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
||||||
this._notifyPaneLayout();
|
this._notifyPaneLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
|
||||||
|
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
|
||||||
|
if (configs.length === 0) return;
|
||||||
|
let any = false;
|
||||||
|
for (const c of configs) {
|
||||||
|
if (this._detachIndicatorEntry(c.id)) any = true;
|
||||||
|
}
|
||||||
|
if (any) this._trimTrailingEmptySubPanes();
|
||||||
|
await this.addIndicatorsBatch(configs);
|
||||||
|
}
|
||||||
|
|
||||||
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
||||||
private _disposeMainMarkersPlugin(): void {
|
private _disposeMainMarkersPlugin(): void {
|
||||||
if (!this.mainMarkersPlugin) return;
|
if (!this.mainMarkersPlugin) return;
|
||||||
@@ -2254,7 +2296,7 @@ export class ChartManager {
|
|||||||
return indices;
|
return indices;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** pane 2+ 빈 sub-pane 제거 — 제거·재로드 후 고아 pane 이 높이를 나눠 가져가는 것 방지 */
|
/** pane 2+ 빈 sub-pane 제거 — reloadIndicatorsOnly 전용 (모든 지표 시리즈 제거 후) */
|
||||||
private _removeExtraSubPanes(): void {
|
private _removeExtraSubPanes(): void {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const panes = this.chart.panes();
|
const panes = this.chart.panes();
|
||||||
@@ -2268,6 +2310,21 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 활성 지표가 없는 맨 끝 sub-pane 만 제거 — 단일 지표 제거 시 나머지 pane 유지 */
|
||||||
|
private _trimTrailingEmptySubPanes(): void {
|
||||||
|
for (;;) {
|
||||||
|
const panes = this.chart.panes();
|
||||||
|
if (panes.length <= 2) return;
|
||||||
|
const last = panes.length - 1;
|
||||||
|
if (this._activeIndicatorPaneIndices().has(last)) return;
|
||||||
|
try {
|
||||||
|
this.chart.removePane(last);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
|
/** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
|
||||||
getPaneIndexAtChartY(chartY: number): number {
|
getPaneIndexAtChartY(chartY: number): number {
|
||||||
const layouts = this.getPaneLayouts();
|
const layouts = this.getPaneLayouts();
|
||||||
@@ -2578,12 +2635,17 @@ export class ChartManager {
|
|||||||
|
|
||||||
/** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */
|
/** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */
|
||||||
getLoadedIndicatorCount(): number {
|
getLoadedIndicatorCount(): number {
|
||||||
let n = 0;
|
return this.getLoadedIndicatorIds().size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 차트에 실제로 그려진 보조지표 id (부분 로드·증분 복구용) */
|
||||||
|
getLoadedIndicatorIds(): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
for (const entry of this.indicators.values()) {
|
for (const entry of this.indicators.values()) {
|
||||||
if (entry.config?.hidden === true) continue;
|
if (entry.config?.hidden === true) continue;
|
||||||
if (entry.cloudPlugin || entry.seriesList.length > 0) n += 1;
|
if (entry.cloudPlugin || entry.seriesList.length > 0) ids.add(entry.id);
|
||||||
}
|
}
|
||||||
return n;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
|
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export const ICHIMOKU_LINE_ROWS: IchimokuLineRowDef[] = [
|
|||||||
{ plotIndex: 0, plotId: 'plot0', paramKey: 'conversionPeriods' },
|
{ plotIndex: 0, plotId: 'plot0', paramKey: 'conversionPeriods' },
|
||||||
{ plotIndex: 1, plotId: 'plot1', paramKey: 'basePeriods' },
|
{ plotIndex: 1, plotId: 'plot1', paramKey: 'basePeriods' },
|
||||||
{ plotIndex: 2, plotId: 'plot2', paramKey: 'displacement' },
|
{ plotIndex: 2, plotId: 'plot2', paramKey: 'displacement' },
|
||||||
{ plotIndex: 3, plotId: 'plot3', paramKey: 'laggingSpan2Periods' },
|
{ plotIndex: 3, plotId: 'plot3' },
|
||||||
{ plotIndex: 4, plotId: 'plot4', paramKey: 'displacement' },
|
{ plotIndex: 4, plotId: 'plot4', paramKey: 'laggingSpan2Periods' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ICHIMOKU_DEFAULT_PARAMS: Record<string, number> = {
|
export const ICHIMOKU_DEFAULT_PARAMS: Record<string, number> = {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { IndicatorConfig } from '../types';
|
||||||
|
|
||||||
|
export type IndicatorChartSyncMode =
|
||||||
|
| 'none'
|
||||||
|
| 'remove-only'
|
||||||
|
| 'add-only'
|
||||||
|
| 'params-changed'
|
||||||
|
| 'reorder-only'
|
||||||
|
| 'full';
|
||||||
|
|
||||||
|
export interface IndicatorChartChange {
|
||||||
|
mode: IndicatorChartSyncMode;
|
||||||
|
removedIds: string[];
|
||||||
|
added: IndicatorConfig[];
|
||||||
|
paramsChangedIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function singleIndParamKey(i: IndicatorConfig): string {
|
||||||
|
return `${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이전·현재 지표 목록 차이를 분류해 최소 갱신 경로를 선택 */
|
||||||
|
export function classifyIndicatorChartChange(
|
||||||
|
prev: IndicatorConfig[],
|
||||||
|
curr: IndicatorConfig[],
|
||||||
|
): IndicatorChartChange {
|
||||||
|
const prevMap = new Map(prev.map(i => [i.id, i]));
|
||||||
|
const currMap = new Map(curr.map(i => [i.id, i]));
|
||||||
|
|
||||||
|
const removedIds = prev.filter(i => !currMap.has(i.id)).map(i => i.id);
|
||||||
|
const added = curr.filter(i => !prevMap.has(i.id));
|
||||||
|
|
||||||
|
const paramsChangedIds: string[] = [];
|
||||||
|
for (const c of curr) {
|
||||||
|
const p = prevMap.get(c.id);
|
||||||
|
if (!p) continue;
|
||||||
|
if (singleIndParamKey(p) !== singleIndParamKey(c)) {
|
||||||
|
paramsChangedIds.push(c.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty: IndicatorChartChange = {
|
||||||
|
mode: 'none',
|
||||||
|
removedIds,
|
||||||
|
added,
|
||||||
|
paramsChangedIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (removedIds.length === 0 && added.length === 0 && paramsChangedIds.length === 0) {
|
||||||
|
const sameSet = prev.length === curr.length
|
||||||
|
&& prev.every(p => currMap.has(p.id));
|
||||||
|
if (sameSet && prev.map(i => i.id).join(',') !== curr.map(i => i.id).join(',')) {
|
||||||
|
return { ...empty, mode: 'reorder-only' };
|
||||||
|
}
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedIds.length > 0 && added.length === 0 && paramsChangedIds.length === 0) {
|
||||||
|
return { ...empty, mode: 'remove-only' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.length > 0 && removedIds.length === 0 && paramsChangedIds.length === 0) {
|
||||||
|
return { ...empty, mode: 'add-only' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
paramsChangedIds.length > 0
|
||||||
|
&& removedIds.length === 0
|
||||||
|
&& added.length === 0
|
||||||
|
) {
|
||||||
|
return { ...empty, mode: 'params-changed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...empty, mode: 'full' };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { normalizeIchimokuConfig } from './ichimokuConfig';
|
import { normalizeIchimokuConfig } from './ichimokuConfig';
|
||||||
import type { IchimokuCloudColors } from './ichimokuConfig';
|
import type { IchimokuCloudColors } from './ichimokuConfig';
|
||||||
import { isMainIndicatorType } from './indicatorMainTab';
|
import { isMainIndicatorType } from './indicatorMainTab';
|
||||||
|
import { indicatorDefaultsFingerprint } from './indicatorDefaultsFingerprint';
|
||||||
|
|
||||||
export type MainIndicatorDraftMap = Record<string, IndicatorConfig>;
|
export type MainIndicatorDraftMap = Record<string, IndicatorConfig>;
|
||||||
|
|
||||||
@@ -93,6 +94,42 @@ function finalizeMainIndicatorConfig(cfg: IndicatorConfig, isNew: boolean): Indi
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DB에 저장된 전역 기본값 → 차트 인스턴스에 반영 (id·hidden·pane 병합 상태 유지) */
|
||||||
|
export function mergeGlobalDefaultsOntoChartIndicators(
|
||||||
|
activeIndicators: IndicatorConfig[],
|
||||||
|
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||||
|
getVisualConfig: (
|
||||||
|
type: string,
|
||||||
|
defaultPlots?: PlotDef[],
|
||||||
|
defaultHlines?: HLineDef[],
|
||||||
|
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors },
|
||||||
|
): IndicatorConfig[] {
|
||||||
|
return activeIndicators.map(ind => {
|
||||||
|
const cfg = buildMainIndicatorConfig(ind.type, [ind], getParams, getVisualConfig);
|
||||||
|
if (!cfg) return enrichIndicatorConfig(ind);
|
||||||
|
return finalizeMainIndicatorConfig(
|
||||||
|
{
|
||||||
|
...cfg,
|
||||||
|
id: ind.id,
|
||||||
|
hidden: ind.hidden,
|
||||||
|
mergedWith: ind.mergedWith,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전역 기본값 병합 후 실제 렌더 설정이 바뀌었는지 */
|
||||||
|
export function chartIndicatorsNeedDefaultsRefresh(
|
||||||
|
before: IndicatorConfig[],
|
||||||
|
after: IndicatorConfig[],
|
||||||
|
): boolean {
|
||||||
|
if (before.length !== after.length) return true;
|
||||||
|
return before.some((ind, i) =>
|
||||||
|
indicatorDefaultsFingerprint(ind) !== indicatorDefaultsFingerprint(after[i]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Main 탭 설정 → 현재 차트에 올라간 동일 type 인스턴스에 반영 (id·hidden 유지) */
|
/** Main 탭 설정 → 현재 차트에 올라간 동일 type 인스턴스에 반영 (id·hidden 유지) */
|
||||||
export function mergeMainDraftOntoChart(
|
export function mergeMainDraftOntoChart(
|
||||||
activeIndicators: IndicatorConfig[],
|
activeIndicators: IndicatorConfig[],
|
||||||
|
|||||||
@@ -202,3 +202,22 @@ export function buildIndicatorConfigsFromTypes(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** id 제거 — 병합 호스트 제거 시 해당 pane 멤버도 함께 제거 */
|
||||||
|
export function removeIndicatorFromList(
|
||||||
|
prev: IndicatorConfig[],
|
||||||
|
id: string,
|
||||||
|
): IndicatorConfig[] {
|
||||||
|
return prev.filter(i => i.id !== id && i.mergedWith !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** type 제거 — 해당 type 인스턴스 및 병합 멤버 함께 제거 */
|
||||||
|
export function removeIndicatorTypeFromList(
|
||||||
|
prev: IndicatorConfig[],
|
||||||
|
type: string,
|
||||||
|
): IndicatorConfig[] {
|
||||||
|
const removedIds = new Set(prev.filter(i => i.type === type).map(i => i.id));
|
||||||
|
return prev.filter(
|
||||||
|
i => i.type !== type && (!i.mergedWith || !removedIds.has(i.mergedWith)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ export const INDICATOR_PLOT_PARAMS: Record<string, Record<string, string[]>> = {
|
|||||||
IchimokuCloud: {
|
IchimokuCloud: {
|
||||||
plot0: ['conversionPeriods'],
|
plot0: ['conversionPeriods'],
|
||||||
plot1: ['basePeriods'],
|
plot1: ['basePeriods'],
|
||||||
/** 선행스팬1 — 전환·기준 평균선의 미래 이동(shift) 기간 */
|
/** 후행스팬 — chikou 이동(displacement) */
|
||||||
plot2: ['displacement'],
|
plot2: ['displacement'],
|
||||||
plot3: ['laggingSpan2Periods'],
|
/** 선행스팬1 — 전환·기준선에서 산출, 별도 기간 없음 */
|
||||||
/** 후행스팬 — chikou 이동(선행스팬1과 동일 displacement) */
|
plot3: [],
|
||||||
plot4: ['displacement'],
|
/** 선행스팬2 — Donchian 기간 */
|
||||||
|
plot4: ['laggingSpan2Periods'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user