가상매매 화면오류 수정

This commit is contained in:
Macbook
2026-06-08 21:35:25 +09:00
parent 77562e5b8f
commit c9b0441500
2 changed files with 78 additions and 28 deletions
+45 -13
View File
@@ -178,6 +178,10 @@ interface TradingChartProps {
custom1OverlayActive?: boolean; custom1OverlayActive?: boolean;
onCustom1OverlayActiveChange?: (active: boolean) => void; onCustom1OverlayActiveChange?: (active: boolean) => void;
onOpenCustom1OverlaySettings?: () => void; onOpenCustom1OverlaySettings?: () => void;
/** 미니 차트 — plot 너비에 맞는 초기 x축 캔들 수 (미지정 시 DISPLAY_COUNT) */
resolveInitialDisplayCount?: (plotWidth: number) => number;
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
skipViewportRecovery?: boolean;
} }
const TradingChart: React.FC<TradingChartProps> = ({ const TradingChart: React.FC<TradingChartProps> = ({
@@ -231,6 +235,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
custom1OverlayActive, custom1OverlayActive,
onCustom1OverlayActiveChange, onCustom1OverlayActiveChange,
onOpenCustom1OverlaySettings, onOpenCustom1OverlaySettings,
resolveInitialDisplayCount,
skipViewportRecovery = false,
}) => { }) => {
const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend; const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend;
const showOverlayControlsOnly = !!( const showOverlayControlsOnly = !!(
@@ -316,6 +322,17 @@ const TradingChart: React.FC<TradingChartProps> = ({
const recoveryTimerRef = useRef<number | null>(null); const recoveryTimerRef = useRef<number | null>(null);
const resizeDebounceRef = useRef<number | null>(null); const resizeDebounceRef = useRef<number | null>(null);
const chartVisibleRef = useRef(chartVisible); const chartVisibleRef = useRef(chartVisible);
const resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount);
const skipViewportRecoveryRef = useRef(skipViewportRecovery);
const lastViewportFitRef = useRef<{ count: number; width: number } | null>(null);
useEffect(() => {
resolveInitialDisplayCountRef.current = resolveInitialDisplayCount;
}, [resolveInitialDisplayCount]);
useEffect(() => {
skipViewportRecoveryRef.current = skipViewportRecovery;
}, [skipViewportRecovery]);
const chartTimeFormat = useChartTimeFormat(); const chartTimeFormat = useChartTimeFormat();
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null); const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
@@ -548,15 +565,31 @@ const TradingChart: React.FC<TradingChartProps> = ({
userDraggedRatioRef.current = null; userDraggedRatioRef.current = null;
}, [paneAreaRatio]); }, [paneAreaRatio]);
const getInitialDisplayCount = useCallback((): number => {
const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0;
const resolver = resolveInitialDisplayCountRef.current;
return resolver ? resolver(plotW) : DISPLAY_COUNT;
}, []);
const applyInitialVisibleRange = useCallback((mgr: ChartManager) => {
const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0;
if (plotW <= 0) return;
const count = getInitialDisplayCount();
const prev = lastViewportFitRef.current;
if (prev && prev.count === count && Math.abs(prev.width - plotW) < 4) return;
lastViewportFitRef.current = { count, width: plotW };
const fb = mgr.hasIchimoku() ? 28 : 0;
mgr.setInitialVisibleRange(count, fb);
}, [getInitialDisplayCount]);
/** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */ /** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */
const normalizeChartViewport = useCallback((mgr: ChartManager) => { const normalizeChartViewport = useCallback((mgr: ChartManager) => {
applyPaneLayout(mgr); applyPaneLayout(mgr);
requestAnimationFrame(() => { requestAnimationFrame(() => {
try { mgr.autoScale(); } catch { /* ok */ } try { mgr.autoScale(); } catch { /* ok */ }
const fb = mgr.hasIchimoku() ? 28 : 0; applyInitialVisibleRange(mgr);
mgr.setInitialVisibleRange(DISPLAY_COUNT, fb);
}); });
}, [applyPaneLayout]); }, [applyPaneLayout, applyInitialVisibleRange]);
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */ /** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
useEffect(() => { useEffect(() => {
@@ -573,13 +606,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
const wrapperH = wrapperRef.current?.clientHeight ?? 0; const wrapperH = wrapperRef.current?.clientHeight ?? 0;
m.restoreFromCandleFullscreen(wrapperH); m.restoreFromCandleFullscreen(wrapperH);
applyPaneLayout(m); applyPaneLayout(m);
const futureBars = m.hasIchimoku() ? 28 : 0; applyInitialVisibleRange(m);
m.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
}; };
requestAnimationFrame(() => requestAnimationFrame(runRestore)); requestAnimationFrame(() => requestAnimationFrame(runRestore));
[200, 500, 1000].forEach(delay => setTimeout(runRestore, delay)); [200, 500, 1000].forEach(delay => setTimeout(runRestore, delay));
} }
}, [candleOnlyMode, chartMgr, applyPaneLayout]); }, [candleOnlyMode, chartMgr, applyPaneLayout, applyInitialVisibleRange]);
// ── 전체 재로드 (데이터 + 인디케이터) ───────────────────────────────────── // ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]); const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
@@ -778,11 +810,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
requestAnimationFrame(() => { requestAnimationFrame(() => {
applyPaneLayout(mgr); applyPaneLayout(mgr);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const futureBars = mgr.hasIchimoku() ? 28 : 0; applyInitialVisibleRange(mgr);
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
}); });
}); });
}, [applyPaneLayout]); }, [applyPaneLayout, applyInitialVisibleRange]);
const reloadAll = useCallback(async ( const reloadAll = useCallback(async (
mgr: ChartManager, mgr: ChartManager,
@@ -887,6 +918,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
queueIndicatorRecovery(); queueIndicatorRecovery();
return; return;
} }
if (skipViewportRecoveryRef.current) return;
const w = wrapperRef.current; const w = wrapperRef.current;
if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return; if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return;
normalizeChartViewport(m); normalizeChartViewport(m);
@@ -1233,8 +1265,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
if (savedLR) { if (savedLR) {
m.applyVisibleLogicalRange(savedLR.from, savedLR.to); m.applyVisibleLogicalRange(savedLR.from, savedLR.to);
} else { } else {
const fb = m.hasIchimoku() ? 28 : 0; applyInitialVisibleRange(m);
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
} }
}); });
} }
@@ -1528,10 +1559,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
const layoutRecoveryAttemptedRef = useRef(false); const layoutRecoveryAttemptedRef = useRef(false);
useEffect(() => { useEffect(() => {
layoutRecoveryAttemptedRef.current = false; layoutRecoveryAttemptedRef.current = false;
lastViewportFitRef.current = null;
}, [market, timeframe]); }, [market, timeframe]);
useEffect(() => { useEffect(() => {
if (!chartVisible) return; if (!chartVisible || skipViewportRecovery) 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;
@@ -1561,7 +1593,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
); );
return () => { timers.forEach(clearTimeout); }; return () => { timers.forEach(clearTimeout); };
}, [chartMgr, bars.length, barsMarket, market, chartVisible, normalizeChartViewport]); }, [chartMgr, bars.length, barsMarket, market, chartVisible, normalizeChartViewport, skipViewportRecovery]);
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후) // 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
useEffect(() => { useEffect(() => {
@@ -22,7 +22,15 @@ import {
resolveStrategyTimeframes, resolveStrategyTimeframes,
} from '../../utils/strategyToChartIndicators'; } from '../../utils/strategyToChartIndicators';
import { timeframeToCandleType } from '../../utils/chartCandleType'; import { timeframeToCandleType } from '../../utils/chartCandleType';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { DISPLAY_COUNT } from '../../hooks/useUpbitData';
/** 미니 카드 폭에 맞춰 표시할 캔들 수 (좌우 빈 공간·x축 라벨 겹침 방지) */
const COMPACT_MIN_BAR_PX = 5;
function resolveCompactDisplayCount(width: number): number {
if (width <= 0) return DISPLAY_COUNT;
return Math.max(40, Math.min(DISPLAY_COUNT, Math.floor(width / COMPACT_MIN_BAR_PX)));
}
interface Props { interface Props {
market: string; market: string;
@@ -81,6 +89,11 @@ const VirtualTargetCardChart: React.FC<Props> = ({
[indicators, timeframe], [indicators, timeframe],
); );
const resolveInitialDisplayCount = useCallback(
(plotWidth: number) => resolveCompactDisplayCount(plotWidth),
[],
);
const managerRef = useRef<ChartManager | null>(null); const managerRef = useRef<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null); const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const [chartReloadTick, setChartReloadTick] = useState(0); const [chartReloadTick, setChartReloadTick] = useState(0);
@@ -106,9 +119,10 @@ const VirtualTargetCardChart: React.FC<Props> = ({
chartLiveReadyRef.current = true; chartLiveReadyRef.current = true;
const pending = pendingBarRef.current; const pending = pendingBarRef.current;
const mgr = managerRef.current; const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return; if (pending && mgr && barsMarketRef.current === marketRef.current) {
pendingBarRef.current = null; pendingBarRef.current = null;
mgr.updateBar(pending); mgr.updateBar(pending);
}
syncChartLayout(); syncChartLayout();
}, [syncChartLayout]); }, [syncChartLayout]);
@@ -155,7 +169,6 @@ const VirtualTargetCardChart: React.FC<Props> = ({
const barsReady = bars.length >= 2 && barsMarket === market; const barsReady = bars.length >= 2 && barsMarket === market;
const requestChartReload = useCallback(() => { const requestChartReload = useCallback(() => {
chartReloadTriggeredRef.current = true; chartReloadTriggeredRef.current = true;
setChartReloadTick(t => t + 1); setChartReloadTick(t => t + 1);
@@ -171,36 +184,39 @@ const VirtualTargetCardChart: React.FC<Props> = ({
const wrap = canvasWrapRef.current; const wrap = canvasWrapRef.current;
if (!wrap) return; if (!wrap) return;
const timers = [200, 600, 1200].map(delay => let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleLayoutSync = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => syncChartLayout(), 120);
};
const timers = [600].map(delay =>
setTimeout(() => { setTimeout(() => {
if (!canvasWrapRef.current) return; if (!canvasWrapRef.current) return;
if (managerRef.current?.hasMainSeries()) { if (managerRef.current?.hasMainSeries()) {
syncChartLayout(); syncChartLayout();
} else if (!chartReloadTriggeredRef.current && delay >= 600 && barsReady) { } else if (!chartReloadTriggeredRef.current && barsReady) {
requestChartReload(); requestChartReload();
} }
}, delay), }, delay),
); );
const ro = new ResizeObserver(() => syncChartLayout()); const ro = new ResizeObserver(() => scheduleLayoutSync());
ro.observe(wrap); ro.observe(wrap);
return () => { return () => {
timers.forEach(clearTimeout); timers.forEach(clearTimeout);
if (resizeTimer) clearTimeout(resizeTimer);
ro.disconnect(); ro.disconnect();
}; };
}, [market, timeframe, barsReady, syncChartLayout, requestChartReload]); }, [market, timeframe, barsReady, syncChartLayout, requestChartReload]);
useEffect(() => { useEffect(() => {
if (!barsReady) return; if (!barsReady) return;
if (managerRef.current?.hasMainSeries()) { if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) {
syncChartLayout();
return;
}
if (!chartReloadTriggeredRef.current) {
requestChartReload(); requestChartReload();
} }
}, [barsReady, syncChartLayout, requestChartReload]); }, [barsReady, requestChartReload]);
useEffect(() => { useEffect(() => {
if (!streamActive) return; if (!streamActive) return;
@@ -242,7 +258,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
<div className="vtd-card-chart-loading"> </div> <div className="vtd-card-chart-loading"> </div>
)} )}
<TradingChart <TradingChart
key={`${market}-${timeframe}-${chartReloadTick}`} key={`${market}-${timeframe}-${chartReloadTick}-${effectiveIndicators.map(i => i.id).join(',')}`}
chartVisible={chartStreamEnabled} chartVisible={chartStreamEnabled}
bars={bars} bars={bars}
barsMarket={barsMarket} barsMarket={barsMarket}
@@ -261,6 +277,8 @@ const VirtualTargetCardChart: React.FC<Props> = ({
onManagerReady={mgr => { managerRef.current = mgr; }} onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void} onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady} onCandlesReady={handleCandlesReady}
resolveInitialDisplayCount={resolveInitialDisplayCount}
skipViewportRecovery
magnifierEnabled={false} magnifierEnabled={false}
volumeVisible={false} volumeVisible={false}
paneLayoutClamp paneLayoutClamp