From 7a0af36b9bc635bf32d5080418c417e32f4fd4de Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 27 May 2026 14:54:50 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=B1=EC=8B=A0=EC=8B=9C=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EB=96=A8=EB=A6=BC=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 47 ++- frontend/src/components/ChartSlot.tsx | 12 +- frontend/src/components/IndicatorModal.tsx | 10 +- frontend/src/components/TradingChart.tsx | 320 +++++++++++++----- frontend/src/utils/ChartManager.ts | 114 +++++-- frontend/src/utils/ichimokuConfig.ts | 4 +- frontend/src/utils/indicatorChartSync.ts | 75 ++++ frontend/src/utils/indicatorMainConfig.ts | 37 ++ frontend/src/utils/indicatorPaneMerge.ts | 19 ++ frontend/src/utils/indicatorSettingsLayout.ts | 9 +- 10 files changed, 517 insertions(+), 130 deletions(-) create mode 100644 frontend/src/utils/indicatorChartSync.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4eb7906..fd221a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,9 +30,11 @@ import { enrichIndicatorConfig, getIndicatorDef } from './utils/indicatorRegistr import { initializeIndicatorConfigForEditor } from './utils/indicatorSettingsEditor'; import { buildMainIndicatorConfig, + chartIndicatorsNeedDefaultsRefresh, indicatorSettingsTemplateId, indicatorTypeFromSettingsId, isIndicatorSettingsTemplateId, + mergeGlobalDefaultsOntoChartIndicators, } from './utils/indicatorMainConfig'; import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig'; import { normalizeIchimokuConfig } from './utils/ichimokuConfig'; @@ -49,6 +51,8 @@ import { duplicateIndicatorInList, mergeIndicators, reorderIndicatorGroup, + removeIndicatorFromList, + removeIndicatorTypeFromList, splitIndicatorPane, replaceIndicatorConfigsFromTypes, } from './utils/indicatorPaneMerge'; @@ -199,6 +203,7 @@ function App() { const [mobileRightOpen, setMobileRightOpen] = useState(false); const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade'); const [menuPage, setMenuPage] = useState('chart'); + const chartVisible = menuPage === 'chart'; const isMobile = useIsMobile(); // ── 마켓 패널 시세 데이터 ───────────────────────────────────────────── @@ -1393,7 +1398,7 @@ function App() { const slotRef = slotRefs.current[activeSlot]; if (slotRef) { slotRef.removeIndicatorByType(type); return; } } - setIndicators(prev => prev.filter(i => i.type !== type)); + setIndicators(prev => removeIndicatorTypeFromList(prev, type)); }, [layoutDef.count, activeSlot]); /** @@ -1480,6 +1485,41 @@ function App() { 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 + 차트 인스턴스 병합 */ const handleBulkIndicatorApply = useCallback((result: { chartIndicators: IndicatorConfig[]; @@ -1511,7 +1551,7 @@ function App() { }, [layoutDef.count, activeSlot]); const handleRemoveIndicatorById = useCallback((id: string) => { - setIndicators(prev => prev.filter(i => i.id !== id)); + setIndicators(prev => removeIndicatorFromList(prev, id)); setFocusedIndicatorId(prev => prev === id ? null : prev); setCtxToolbar(null); setSettingsModalId(null); @@ -2038,6 +2078,7 @@ function App() { chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} compactMode + chartVisible={chartVisible} /> {/* 슬롯 1~7 */} @@ -2075,6 +2116,7 @@ function App() { chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} compactMode + chartVisible={chartVisible} /> ))} @@ -2114,6 +2156,7 @@ function App() { (function ChartSlot({ @@ -190,6 +194,7 @@ const ChartSlot = forwardRef(function ChartSlot displayTimezone, compactMode = false, chartLiveReceiveHighlight = true, + chartVisible = true, }, ref) { // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings(); @@ -278,7 +283,7 @@ const ChartSlot = forwardRef(function ChartSlot ); }, removeIndicatorByType: (type: string) => { - setIndicators(prev => prev.filter(i => i.type !== type)); + setIndicators(prev => removeIndicatorTypeFromList(prev, type)); }, duplicateIndicator: (sourceId: string) => { setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev); @@ -556,7 +561,7 @@ const ChartSlot = forwardRef(function ChartSlot }, []); const handleRemoveById = useCallback((id: string) => { - setIndicators(prev => prev.filter(i => i.id !== id)); + setIndicators(prev => removeIndicatorFromList(prev, id)); setCtxToolbar(null); setSettingsModalId(null); }, []); @@ -588,7 +593,7 @@ const ChartSlot = forwardRef(function ChartSlot }, []); const handleRemoveByIdSlot = useCallback((id: string) => { - setIndicators(prev => prev.filter(i => i.id !== id)); + setIndicators(prev => removeIndicatorFromList(prev, id)); setFocusedIndicatorId(prev => prev === id ? null : prev); setCtxToolbar(null); setSettingsModalId(null); @@ -784,6 +789,7 @@ const ChartSlot = forwardRef(function ChartSlot ) => void; onRemove: (id: string) => void; + /** type 단위 일괄 제거 (여러 인스턴스를 한 번에 제거) */ + onRemoveByType?: (type: string) => void; onClose: () => void; } @@ -17,7 +19,9 @@ const CATEGORIES: IndicatorCategory[] = [ 'Trend', 'Volatility', 'Volume', 'Candlestick Patterns', ]; -const IndicatorModal: React.FC = ({ activeIndicators, onAdd, onRemove, onClose }) => { +const IndicatorModal: React.FC = ({ + activeIndicators, onAdd, onRemove, onRemoveByType, onClose, +}) => { const [search, setSearch] = useState(''); const [category, setCategory] = useState('All'); const [selected, setSelected] = useState(null); @@ -45,6 +49,10 @@ const IndicatorModal: React.FC = ({ activeIndicators, onAdd }; const handleRemoveAll = (type: string) => { + if (onRemoveByType) { + onRemoveByType(type); + return; + } activeIndicators.filter(a => a.type === type).forEach(a => onRemove(a.id)); }; diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index b8b62ec..794a22b 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -8,6 +8,27 @@ import { DISPLAY_COUNT } from '../hooks/useUpbitData'; /** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */ 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 확정 후 렌더 허용 */ function canApplyChartBars( barData: OHLCVBar[], @@ -29,6 +50,7 @@ import ChartContextMenu from './ChartContextMenu'; import CandlePaneControls from './CandlePaneControls'; import { getKoreanName } from '../utils/marketNameCache'; import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; +import { classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync'; interface TradingChartProps { bars: OHLCVBar[]; @@ -102,6 +124,8 @@ interface TradingChartProps { showHoverToolbar?: boolean; /** 보조지표 우측 가격축 라벨·설명 (차트 설정, 기본 true) */ seriesPriceLabelsEnabled?: boolean; + /** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */ + chartVisible?: boolean; } const TradingChart: React.FC = ({ @@ -131,6 +155,7 @@ const TradingChart: React.FC = ({ volumeVisible = true, showHoverToolbar = true, seriesPriceLabelsEnabled = true, + chartVisible = true, }) => { const containerRef = useRef(null); const wrapperRef = useRef(null); // 스크롤 래퍼 @@ -180,6 +205,8 @@ const TradingChart: React.FC = ({ const prevBarsKey = useRef(''); const prevIndKey = useRef(''); const prevSortedPKRef = useRef(''); // 순서 무관 paramKey (reorder 감지용) + const prevIndicatorsListRef = useRef(indicators); + const indicatorSyncInFlightRef = useRef(false); const prevMarket = useRef(market); const prevMarketTf = useRef(timeframe); const lastAppliedMarketRef = useRef(''); @@ -188,6 +215,8 @@ const TradingChart: React.FC = ({ const prevLogScale = useRef(logScale); const indicatorsRef = useRef(indicators); const recoveryTimerRef = useRef | null>(null); + const resizeDebounceRef = useRef | null>(null); + const chartVisibleRef = useRef(chartVisible); const [chartMgr, setChartMgr] = useState(null); /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */ @@ -205,6 +234,10 @@ const TradingChart: React.FC = ({ indicatorsRef.current = indicators; }, [indicators]); + useEffect(() => { + chartVisibleRef.current = chartVisible; + }, [chartVisible]); + const toggleCandleOnly = useCallback(() => { setCandleOnlyMode(v => !v); }, []); @@ -326,7 +359,71 @@ const TradingChart: React.FC = ({ inds: IndicatorConfig[], ) => Promise>(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(() => { + if (!chartVisibleRef.current) return; if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current); recoveryTimerRef.current = window.setTimeout(() => { recoveryTimerRef.current = null; @@ -345,6 +442,7 @@ const TradingChart: React.FC = ({ }, [market, barsMarket]); const queueIndicatorRecovery = useCallback(() => { + if (!chartVisibleRef.current) return; if (reloadInFlightRef.current) return; if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current); recoveryTimerRef.current = window.setTimeout(() => { @@ -365,10 +463,10 @@ const TradingChart: React.FC = ({ const ls = prevLogScale.current; if (m.hasMainSeries()) { - void m.reloadIndicatorsOnly(inds).then(() => { + void repairMissingIndicators(m, inds).then(() => { if (m.getLoadedIndicatorCount() >= expected) { prevBarsKey.current = barsKey(barData, market); - prevIndKey.current = indKey(inds); + syncIndicatorTrackingRefs(inds); applyPaneLayout(m); return; } @@ -378,7 +476,7 @@ const TradingChart: React.FC = ({ } reloadAllRef.current?.(m, barData, ct, th, ls, inds); }, 120); - }, [market, barsMarket, applyPaneLayout]); + }, [market, barsMarket, applyPaneLayout, repairMissingIndicators, syncIndicatorTrackingRefs]); /** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */ const commitCandleLayout = useCallback((mgr: ChartManager) => { @@ -474,8 +572,7 @@ const TradingChart: React.FC = ({ prevTheme.current = th; prevLogScale.current = ls; prevBarsKey.current = barsKey(barData, market); - prevIndKey.current = indKey(inds); - prevSortedPKRef.current = sortedParamKey(inds); + syncIndicatorTrackingRefs(inds); lastAppliedMarketRef.current = market; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { @@ -768,33 +865,42 @@ const TradingChart: React.FC = ({ prevW = width; prevH = height; if (width <= 0 || height <= 0) return; - const m = managerRef.current; - if (!m) return; + if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current); + resizeDebounceRef.current = window.setTimeout(() => { + resizeDebounceRef.current = null; + const m = managerRef.current; + if (!m) return; - // 컨테이너가 처음으로 유효한 크기를 가질 때: - // 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드 - if (barsRef.current.length > 0 && !m.hasMainSeries()) { - const bm = barsMarketRef.current; - if (!canApplyChartBars(barsRef.current, bm, marketRef.current)) return; - void reloadAll( - m, - barsRef.current, - prevChartType.current, - prevTheme.current, - prevLogScale.current, - indicatorsRef.current, - ); - } else { + // 컨테이너가 처음으로 유효한 크기를 가질 때: + // 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드 + if (barsRef.current.length > 0 && !m.hasMainSeries()) { + const bm = barsMarketRef.current; + if (!canApplyChartBars(barsRef.current, bm, marketRef.current)) return; + void reloadAll( + m, + barsRef.current, + prevChartType.current, + prevTheme.current, + prevLogScale.current, + indicatorsRef.current, + ); + return; + } + + const savedLR = wasHidden && m.hasMainSeries() ? m.getVisibleLogicalRange() : null; applyPaneLayout(m); - // 멀티→단일 레이아웃 전환처럼 숨겨졌다가 다시 표시될 때 - // setInitialVisibleRange 를 재실행해 가시 범위(캔들 분포)를 정상화 + // 숨김→표시 전환: 줌/스크롤 위치 유지 (setInitialVisibleRange 로 초기화하면 화면이 튐) if (wasHidden && m.hasMainSeries()) { requestAnimationFrame(() => { - const fb = m.hasIchimoku() ? 28 : 0; - m.setInitialVisibleRange(DISPLAY_COUNT, fb); + if (savedLR) { + m.applyVisibleLogicalRange(savedLR.from, savedLR.to); + } else { + const fb = m.hasIchimoku() ? 28 : 0; + m.setInitialVisibleRange(DISPLAY_COUNT, fb); + } }); } - } + }, 80); }); ro.observe(containerRef.current); @@ -832,6 +938,7 @@ const TradingChart: React.FC = ({ reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current); + if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current); ro.disconnect(); managerRef.current?.destroy(); managerRef.current = null; @@ -851,6 +958,7 @@ const TradingChart: React.FC = ({ prevMarketTf.current = timeframe; prevBarsKey.current = ''; prevIndKey.current = ''; + prevIndicatorsListRef.current = []; lastAppliedMarketRef.current = ''; barsRef.current = []; reloadRetryRef.current = 0; @@ -869,6 +977,16 @@ const TradingChart: React.FC = ({ if (!mgr || !chartMgr || bars.length < 2) 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 강제 if (!mgr.hasMainSeries() || lastAppliedMarketRef.current !== market) { void reloadAll(mgr, bars, chartType, theme, logScale, indicators); @@ -896,91 +1014,105 @@ const TradingChart: React.FC = ({ return; } - if (barsChanged || ctChanged || indicatorsIncomplete) { - // 데이터·차트 형식 변경 또는 지표 누락 → 전체 재로드 + if (barsChanged || ctChanged) { 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) { prevLogScale.current = logScale; mgr.setLogScale(logScale); } else if (indChanged) { + const prevList = prevIndicatorsListRef.current; const prevPK = prevIndKey.current.split('@@')[0] ?? ''; const currPK = paramKey(indicators); const prevSK = prevIndKey.current.split('@@')[1] ?? ''; const currSK = styleKey(indicators); const needsRecalc = prevPK !== currPK; - // 같은 지표 집합인데 순서만 바뀐 경우 (reorder-only) const isReorderOnly = needsRecalc - && sortedParamKey(indicators) === prevSortedPKRef.current + && sortedParamKey(indicators) === sortedParamKey(prevList) && currSK === prevSK; - if (isReorderOnly) { - // ── pane 순서 변경만: 메인 차트를 건드리지 않고 지표만 재배치 ────────── - // 1) 지표 영역만 커버 (순간 숨김) → 2) 지표만 재추가 → 3) 서서히 공개 - prevSortedPKRef.current = sortedParamKey(indicators); - prevIndKey.current = ik; + const prevMap = new Map(prevList.map(i => [i.id, i])); + const removedIds = prevList + .filter(p => !indicators.some(c => c.id === p.id)) + .map(p => p.id); + const addedConfigs = indicators.filter(c => !prevMap.has(c.id)); + const paramsChangedConfigs = indicators.filter(c => { + const p = prevMap.get(c.id); + if (!p) return false; + return singleIndParamKey(p) !== singleIndParamKey(c); + }); - const containerEl = containerRef.current; - // 지표 영역 위에 불투명 커버 div 삽입 - let cover: HTMLDivElement | null = null; - 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 후 복원 - 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 재번호 없음 + if (!needsRecalc && prevSK !== currSK) { for (const ind of indicators) { 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 - }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]); + }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators]); // 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후) useEffect(() => { + if (!chartVisible) return; const mgr = managerRef.current; if (!mgr || !chartMgr || bars.length < 2) return; if (!canApplyChartBars(bars, barsMarket, market)) return; @@ -1004,10 +1136,11 @@ const TradingChart: React.FC = ({ ); return () => { timers.forEach(clearTimeout); }; - }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]); + }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible]); // 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구 useEffect(() => { + if (!chartVisible) return; const mgr = managerRef.current; if (!mgr || !chartMgr || bars.length < 2) return; if (!canApplyChartBars(bars, barsMarket, market)) return; @@ -1018,19 +1151,22 @@ const TradingChart: React.FC = ({ const timers = [120, 400, 900].map(delay => window.setTimeout(() => { - if (reloadInFlightRef.current) return; + if (reloadInFlightRef.current || indicatorSyncInFlightRef.current) return; const m = managerRef.current; if (!m || !m.hasMainSeries()) return; if (!canApplyChartBars(bars, barsMarket, market)) return; const exp = countExpectedVisibleIndicators(indicatorsRef.current); if (exp === 0 || m.getLoadedIndicatorCount() >= exp) return; - prevBarsKey.current = ''; - queueIndicatorRecovery(); + void repairMissingIndicators(m, indicatorsRef.current).then(() => { + if (m.getLoadedIndicatorCount() >= exp) return; + prevBarsKey.current = ''; + queueIndicatorRecovery(); + }); }, delay), ); 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 커서로 변경 const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => { diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 9c2ea77..823ad88 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -532,7 +532,10 @@ export class ChartManager { } // ─── Indicators ───────────────────────────────────────────────────────── - async addIndicator(config: IndicatorConfig): Promise { + async addIndicator( + config: IndicatorConfig, + options?: { skipLayout?: boolean }, + ): Promise { config = enrichIndicatorConfig(config); if (this.indicators.has(config.id) || this.rawBars.length === 0) return; const dataGenAtStart = this._dataGeneration; @@ -664,12 +667,50 @@ export class ChartManager { } catch (e) { console.error(`[Indicator] ${config.type} error:`, e); } finally { - if (this._activeIndicatorPaneIndices().size > 0) { + if ( + !options?.skipLayout + && this._activeIndicatorPaneIndices().size > 0 + ) { this.resetPaneHeights(this._lastLayoutAvailableHeight); } } } + /** 여러 지표 추가 후 pane 레이아웃을 한 번만 갱신 */ + async addIndicatorsBatch(configs: IndicatorConfig[]): Promise { + 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()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다. @@ -708,31 +749,32 @@ export class ChartManager { } removeIndicator(id: string): void { - const entry = this.indicators.get(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 */ } - } + this.removeIndicators([id]); + } + + /** 여러 지표 제거 후 pane 레이아웃을 한 번만 갱신 (줌·스크롤 유지에 유리) */ + removeIndicators(ids: string[]): void { + let any = false; + for (const id of ids) { + if (this._detachIndicatorEntry(id)) any = true; } - if (entry.cloudPlugin) { - 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(); + if (!any) return; + this._trimTrailingEmptySubPanes(); this.resetPaneHeights(this._lastLayoutAvailableHeight); this._notifyPaneLayout(); } + /** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */ + async refreshIndicators(configs: IndicatorConfig[]): Promise { + 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 등 시리즈 교체 전 호출) */ private _disposeMainMarkersPlugin(): void { if (!this.mainMarkersPlugin) return; @@ -2254,7 +2296,7 @@ export class ChartManager { return indices; } - /** pane 2+ 빈 sub-pane 제거 — 제거·재로드 후 고아 pane 이 높이를 나눠 가져가는 것 방지 */ + /** pane 2+ 빈 sub-pane 제거 — reloadIndicatorsOnly 전용 (모든 지표 시리즈 제거 후) */ private _removeExtraSubPanes(): void { for (;;) { 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+=보조지표) */ getPaneIndexAtChartY(chartY: number): number { const layouts = this.getPaneLayouts(); @@ -2578,12 +2635,17 @@ export class ChartManager { /** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */ getLoadedIndicatorCount(): number { - let n = 0; + return this.getLoadedIndicatorIds().size; + } + + /** 차트에 실제로 그려진 보조지표 id (부분 로드·증분 복구용) */ + getLoadedIndicatorIds(): Set { + const ids = new Set(); for (const entry of this.indicators.values()) { 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 개수 반환 (데이터 로드 여부 확인용) */ diff --git a/frontend/src/utils/ichimokuConfig.ts b/frontend/src/utils/ichimokuConfig.ts index fb208ed..48d38df 100644 --- a/frontend/src/utils/ichimokuConfig.ts +++ b/frontend/src/utils/ichimokuConfig.ts @@ -29,8 +29,8 @@ export const ICHIMOKU_LINE_ROWS: IchimokuLineRowDef[] = [ { plotIndex: 0, plotId: 'plot0', paramKey: 'conversionPeriods' }, { plotIndex: 1, plotId: 'plot1', paramKey: 'basePeriods' }, { plotIndex: 2, plotId: 'plot2', paramKey: 'displacement' }, - { plotIndex: 3, plotId: 'plot3', paramKey: 'laggingSpan2Periods' }, - { plotIndex: 4, plotId: 'plot4', paramKey: 'displacement' }, + { plotIndex: 3, plotId: 'plot3' }, + { plotIndex: 4, plotId: 'plot4', paramKey: 'laggingSpan2Periods' }, ]; export const ICHIMOKU_DEFAULT_PARAMS: Record = { diff --git a/frontend/src/utils/indicatorChartSync.ts b/frontend/src/utils/indicatorChartSync.ts new file mode 100644 index 0000000..57bf3ad --- /dev/null +++ b/frontend/src/utils/indicatorChartSync.ts @@ -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' }; +} diff --git a/frontend/src/utils/indicatorMainConfig.ts b/frontend/src/utils/indicatorMainConfig.ts index ecf76c1..e51c3d1 100644 --- a/frontend/src/utils/indicatorMainConfig.ts +++ b/frontend/src/utils/indicatorMainConfig.ts @@ -14,6 +14,7 @@ import { import { normalizeIchimokuConfig } from './ichimokuConfig'; import type { IchimokuCloudColors } from './ichimokuConfig'; import { isMainIndicatorType } from './indicatorMainTab'; +import { indicatorDefaultsFingerprint } from './indicatorDefaultsFingerprint'; export type MainIndicatorDraftMap = Record; @@ -93,6 +94,42 @@ function finalizeMainIndicatorConfig(cfg: IndicatorConfig, isNew: boolean): Indi return out; } +/** DB에 저장된 전역 기본값 → 차트 인스턴스에 반영 (id·hidden·pane 병합 상태 유지) */ +export function mergeGlobalDefaultsOntoChartIndicators( + activeIndicators: IndicatorConfig[], + getParams: (type: string, defaults: Record) => Record, + 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 유지) */ export function mergeMainDraftOntoChart( activeIndicators: IndicatorConfig[], diff --git a/frontend/src/utils/indicatorPaneMerge.ts b/frontend/src/utils/indicatorPaneMerge.ts index 561e9a4..0dcc61c 100644 --- a/frontend/src/utils/indicatorPaneMerge.ts +++ b/frontend/src/utils/indicatorPaneMerge.ts @@ -202,3 +202,22 @@ export function buildIndicatorConfigsFromTypes( } 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)), + ); +} diff --git a/frontend/src/utils/indicatorSettingsLayout.ts b/frontend/src/utils/indicatorSettingsLayout.ts index 5903556..c14d402 100644 --- a/frontend/src/utils/indicatorSettingsLayout.ts +++ b/frontend/src/utils/indicatorSettingsLayout.ts @@ -46,11 +46,12 @@ export const INDICATOR_PLOT_PARAMS: Record> = { IchimokuCloud: { plot0: ['conversionPeriods'], plot1: ['basePeriods'], - /** 선행스팬1 — 전환·기준 평균선의 미래 이동(shift) 기간 */ + /** 후행스팬 — chikou 이동(displacement) */ plot2: ['displacement'], - plot3: ['laggingSpan2Periods'], - /** 후행스팬 — chikou 이동(선행스팬1과 동일 displacement) */ - plot4: ['displacement'], + /** 선행스팬1 — 전환·기준선에서 산출, 별도 기간 없음 */ + plot3: [], + /** 선행스팬2 — Donchian 기간 */ + plot4: ['laggingSpan2Periods'], }, };