실시간 차트 갱신시 화면떨림 문제 해결

This commit is contained in:
Macbook
2026-05-27 14:54:50 +09:00
parent f7aac535d8
commit 7a0af36b9b
10 changed files with 517 additions and 130 deletions
+45 -2
View File
@@ -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}
+9 -3
View File
@@ -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 -1
View File
@@ -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));
}; };
+228 -92
View File
@@ -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,33 +865,42 @@ 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;
const m = managerRef.current; if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
if (!m) return; resizeDebounceRef.current = window.setTimeout(() => {
resizeDebounceRef.current = null;
const m = managerRef.current;
if (!m) return;
// 컨테이너가 처음으로 유효한 크기를 가질 때: // 컨테이너가 처음으로 유효한 크기를 가질 때:
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드 // 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
if (barsRef.current.length > 0 && !m.hasMainSeries()) { if (barsRef.current.length > 0 && !m.hasMainSeries()) {
const bm = barsMarketRef.current; const bm = barsMarketRef.current;
if (!canApplyChartBars(barsRef.current, bm, marketRef.current)) return; if (!canApplyChartBars(barsRef.current, bm, marketRef.current)) return;
void reloadAll( void reloadAll(
m, m,
barsRef.current, barsRef.current,
prevChartType.current, prevChartType.current,
prevTheme.current, prevTheme.current,
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(() => {
const fb = m.hasIchimoku() ? 28 : 0; if (savedLR) {
m.setInitialVisibleRange(DISPLAY_COUNT, fb); m.applyVisibleLogicalRange(savedLR.from, savedLR.to);
} else {
const fb = m.hasIchimoku() ? 28 : 0;
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 p = prevMap.get(c.id);
if (!p) return false;
return singleIndParamKey(p) !== singleIndParamKey(c);
});
const containerEl = containerRef.current; if (!needsRecalc && prevSK !== currSK) {
// 지표 영역 위에 불투명 커버 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 재번호 없음
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;
prevBarsKey.current = ''; void repairMissingIndicators(m, indicatorsRef.current).then(() => {
queueIndicatorRecovery(); if (m.getLoadedIndicatorCount() >= exp) return;
prevBarsKey.current = '';
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) => {
+88 -26
View File
@@ -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) { /** 여러 지표 제거 후 pane 레이아웃을 한 번만 갱신 (줌·스크롤 유지에 유리) */
for (const pl of entry.hlineRefs) { removeIndicators(ids: string[]): void {
try { entry.seriesList[0].removePriceLine(pl); } catch { /* ok */ } let any = false;
} for (const id of ids) {
if (entry.fillPrimitive) { if (this._detachIndicatorEntry(id)) any = true;
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
}
} }
if (entry.cloudPlugin) { if (!any) return;
for (const s of entry.seriesList) { this._trimTrailingEmptySubPanes();
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 개수 반환 (데이터 로드 여부 확인용) */
+2 -2
View File
@@ -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> = {
+75
View File
@@ -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' };
}
+37
View File
@@ -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[],
+19
View File
@@ -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'],
}, },
}; };