실시간 차트 갱신시 화면떨림 문제 해결
This commit is contained in:
+45
-2
@@ -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<MenuPage>('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}
|
||||
/>
|
||||
</div>
|
||||
{/* 슬롯 1~7 */}
|
||||
@@ -2075,6 +2116,7 @@ function App() {
|
||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||
displayTimezone={displayTimezone}
|
||||
compactMode
|
||||
chartVisible={chartVisible}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -2114,6 +2156,7 @@ function App() {
|
||||
|
||||
<TradingChart
|
||||
key={chartMountKey}
|
||||
chartVisible={chartVisible}
|
||||
bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
|
||||
chartType={chartType} theme={theme} mode={mode}
|
||||
indicators={effectiveIndicators} drawingTool={drawingTool}
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
duplicateIndicatorInList,
|
||||
mergeIndicators,
|
||||
reorderIndicatorGroup,
|
||||
removeIndicatorFromList,
|
||||
removeIndicatorTypeFromList,
|
||||
splitIndicatorPane,
|
||||
replaceIndicatorConfigsFromTypes,
|
||||
} from '../utils/indicatorPaneMerge';
|
||||
@@ -168,6 +170,8 @@ export interface ChartSlotProps {
|
||||
compactMode?: boolean;
|
||||
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
/** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */
|
||||
chartVisible?: boolean;
|
||||
}
|
||||
|
||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||
@@ -190,6 +194,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
displayTimezone,
|
||||
compactMode = false,
|
||||
chartLiveReceiveHighlight = true,
|
||||
chartVisible = true,
|
||||
}, ref) {
|
||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||
@@ -278,7 +283,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(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<ChartSlotHandle, ChartSlotProps>(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<ChartSlotHandle, ChartSlotProps>(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<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
|
||||
<TradingChart
|
||||
key={reloadTick}
|
||||
chartVisible={chartVisible}
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={symbol}
|
||||
|
||||
@@ -9,6 +9,8 @@ interface IndicatorModalProps {
|
||||
activeIndicators: IndicatorConfig[];
|
||||
onAdd: (def: IndicatorDef, params: Record<string, number | string | boolean>) => 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<IndicatorModalProps> = ({ activeIndicators, onAdd, onRemove, onClose }) => {
|
||||
const IndicatorModal: React.FC<IndicatorModalProps> = ({
|
||||
activeIndicators, onAdd, onRemove, onRemoveByType, onClose,
|
||||
}) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<IndicatorCategory | 'All'>('All');
|
||||
const [selected, setSelected] = useState<IndicatorDef | null>(null);
|
||||
@@ -45,6 +49,10 @@ const IndicatorModal: React.FC<IndicatorModalProps> = ({ activeIndicators, onAdd
|
||||
};
|
||||
|
||||
const handleRemoveAll = (type: string) => {
|
||||
if (onRemoveByType) {
|
||||
onRemoveByType(type);
|
||||
return;
|
||||
}
|
||||
activeIndicators.filter(a => a.type === type).forEach(a => onRemove(a.id));
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TradingChartProps> = ({
|
||||
@@ -131,6 +155,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
volumeVisible = true,
|
||||
showHoverToolbar = true,
|
||||
seriesPriceLabelsEnabled = true,
|
||||
chartVisible = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
@@ -180,6 +205,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevBarsKey = useRef<string>('');
|
||||
const prevIndKey = useRef<string>('');
|
||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
||||
const indicatorSyncInFlightRef = useRef(false);
|
||||
const prevMarket = useRef<string>(market);
|
||||
const prevMarketTf = useRef<Timeframe>(timeframe);
|
||||
const lastAppliedMarketRef = useRef<string>('');
|
||||
@@ -188,6 +215,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevLogScale = useRef<boolean>(logScale);
|
||||
const indicatorsRef = useRef(indicators);
|
||||
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);
|
||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||
@@ -205,6 +234,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
indicatorsRef.current = indicators;
|
||||
}, [indicators]);
|
||||
|
||||
useEffect(() => {
|
||||
chartVisibleRef.current = chartVisible;
|
||||
}, [chartVisible]);
|
||||
|
||||
const toggleCandleOnly = useCallback(() => {
|
||||
setCandleOnlyMode(v => !v);
|
||||
}, []);
|
||||
@@ -326,7 +359,71 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
inds: IndicatorConfig[],
|
||||
) => 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(() => {
|
||||
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<TradingChartProps> = ({
|
||||
}, [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<TradingChartProps> = ({
|
||||
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<TradingChartProps> = ({
|
||||
}
|
||||
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<TradingChartProps> = ({
|
||||
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<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
@@ -768,33 +865,42 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
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<TradingChartProps> = ({
|
||||
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<TradingChartProps> = ({
|
||||
prevMarketTf.current = timeframe;
|
||||
prevBarsKey.current = '';
|
||||
prevIndKey.current = '';
|
||||
prevIndicatorsListRef.current = [];
|
||||
lastAppliedMarketRef.current = '';
|
||||
barsRef.current = [];
|
||||
reloadRetryRef.current = 0;
|
||||
@@ -869,6 +977,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
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<TradingChartProps> = ({
|
||||
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<TradingChartProps> = ({
|
||||
);
|
||||
|
||||
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<TradingChartProps> = ({
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -532,7 +532,10 @@ export class ChartManager {
|
||||
}
|
||||
|
||||
// ─── Indicators ─────────────────────────────────────────────────────────
|
||||
async addIndicator(config: IndicatorConfig): Promise<void> {
|
||||
async addIndicator(
|
||||
config: IndicatorConfig,
|
||||
options?: { skipLayout?: boolean },
|
||||
): Promise<void> {
|
||||
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<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()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다.
|
||||
@@ -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<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 등 시리즈 교체 전 호출) */
|
||||
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<string> {
|
||||
const ids = new Set<string>();
|
||||
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 개수 반환 (데이터 로드 여부 확인용) */
|
||||
|
||||
@@ -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<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 type { IchimokuCloudColors } from './ichimokuConfig';
|
||||
import { isMainIndicatorType } from './indicatorMainTab';
|
||||
import { indicatorDefaultsFingerprint } from './indicatorDefaultsFingerprint';
|
||||
|
||||
export type MainIndicatorDraftMap = Record<string, IndicatorConfig>;
|
||||
|
||||
@@ -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<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 유지) */
|
||||
export function mergeMainDraftOntoChart(
|
||||
activeIndicators: IndicatorConfig[],
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,11 +46,12 @@ export const INDICATOR_PLOT_PARAMS: Record<string, Record<string, string[]>> = {
|
||||
IchimokuCloud: {
|
||||
plot0: ['conversionPeriods'],
|
||||
plot1: ['basePeriods'],
|
||||
/** 선행스팬1 — 전환·기준 평균선의 미래 이동(shift) 기간 */
|
||||
/** 후행스팬 — chikou 이동(displacement) */
|
||||
plot2: ['displacement'],
|
||||
plot3: ['laggingSpan2Periods'],
|
||||
/** 후행스팬 — chikou 이동(선행스팬1과 동일 displacement) */
|
||||
plot4: ['displacement'],
|
||||
/** 선행스팬1 — 전환·기준선에서 산출, 별도 기간 없음 */
|
||||
plot3: [],
|
||||
/** 선행스팬2 — Donchian 기간 */
|
||||
plot4: ['laggingSpan2Periods'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user