From 06897c59f1617e84982c88fabda0d494d28260e5 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 07:40:45 +0800 Subject: [PATCH] Fix market chart viewport jump when switching timeframes. Reset visible range and block stale left-pan/tail refresh from applying the previous period logical range to new candles. Co-authored-by: Cursor --- manual_trading_hub/static/chart.js | 78 +++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index bd1a1f3..8774c13 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -177,6 +177,7 @@ let currentTf = "1d"; let exhaustedLeft = false; let loadingLeft = false; + let chartDataLoading = false; let priceTagTimer = null; let tfDigitBuf = ""; let tfDigitTimer = null; @@ -809,6 +810,7 @@ if (elTf) elTf.value = tf; if (elFsTf) elFsTf.value = tf; currentTf = tf; + lastViewKey = ""; tickLiveClock(); updateHeaderLabels( elSymbol && elSymbol.value.trim().toUpperCase(), @@ -1953,7 +1955,17 @@ chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) { updateVisibleRangeMarkers(); updatePriceTag(); - if (!range || loadingLeft || exhaustedLeft || !lastCandles.length) return; + if ( + !range || + chartDataLoading || + loadingLeft || + exhaustedLeft || + !lastCandles.length || + !lastViewKey + ) { + return; + } + if (currentChartViewKey() !== lastViewKey) return; if (range.from < CHART_LOAD_LEFT_THRESHOLD) { void loadOlderCandles(); } @@ -1996,6 +2008,30 @@ loadingLeft = false; } + function currentChartViewKey() { + const exKey = (elExchange && elExchange.value) || ""; + const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; + const tf = (elTf && elTf.value) || currentTf || "1d"; + if (!exKey || !sym) return ""; + return viewKey(exKey, sym, tf); + } + + function isVisibleRangeValidForCandles(range, candleCount) { + if (!range || candleCount <= 0) return false; + const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS; + if (range.from < -2 || range.to < 0) return false; + if (range.to > maxTo + 8) return false; + if (range.from > candleCount - 1) return false; + return true; + } + + function clearChartSeriesData() { + lastCandles = []; + candleByTime = {}; + if (candleSeries) candleSeries.setData([]); + if (volumeSeries) volumeSeries.setData([]); + } + function mergeCandles(existing, incoming, opts) { opts = opts || {}; const prepend = !!opts.prepend; @@ -2064,11 +2100,13 @@ } async function loadOlderCandles() { - if (loadingLeft || exhaustedLeft || !lastCandles.length) return; + if (chartDataLoading || loadingLeft || exhaustedLeft || !lastCandles.length) return; const exKey = (elExchange && elExchange.value) || ""; const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; const tf = (elTf && elTf.value) || "1d"; if (!exKey || !sym) return; + const vKey = viewKey(exKey, sym, tf); + if (!lastViewKey || vKey !== lastViewKey) return; loadingLeft = true; const beforeMs = Number(lastCandles[0].time) * 1000; try { @@ -2107,8 +2145,11 @@ const exKey = (elExchange && elExchange.value) || ""; const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || ""; const tf = (elTf && elTf.value) || "1d"; - if (!exKey || !sym || !lastCandles.length) return; + const vKey = viewKey(exKey, sym, tf); + if (!exKey || !sym || !lastCandles.length || chartDataLoading) return; + if (!lastViewKey || vKey !== lastViewKey) return; const myToken = loadToken; + const candleCountBefore = lastCandles.length; let savedRange = null; if (chart) savedRange = chart.timeScale().getVisibleLogicalRange(); try { @@ -2119,6 +2160,7 @@ limit: chartChunkLimit(tf), }); if (myToken !== loadToken) return; + if (vKey !== lastViewKey) return; if (!data.ok || !data.candles || !data.candles.length) return; if (data.price_tick != null) { priceTick = data.price_tick; @@ -2130,7 +2172,13 @@ } } applyCandlesToChart(mergeCandles(lastCandles, alignCandlesToTick(data.candles), { prepend: false }), 0); - if (savedRange) chart.timeScale().setVisibleLogicalRange(savedRange); + if ( + savedRange && + isVisibleRangeValidForCandles(savedRange, lastCandles.length) && + Math.abs(lastCandles.length - candleCountBefore) < chartChunkLimit(tf) + ) { + chart.timeScale().setVisibleLogicalRange(savedRange); + } if (posContext) { updateLivePosPnl(); refreshPosPnlFromBoard(); @@ -2155,10 +2203,15 @@ const n = lastCandles.length; const visible = Math.min(DEFAULT_VISIBLE_BARS, n); const from = Math.max(0, n - visible); - // to 延伸到最后一根 K 线之后,留出 RIGHT_OFFSET_BARS 根空白(K 线与价格轴间距) const to = n - 1 + RIGHT_OFFSET_BARS; applyChartRightGap(); - chart.timeScale().setVisibleLogicalRange({ from: from, to: to }); + requestAnimationFrame(function () { + requestAnimationFrame(function () { + if (!chart || !lastCandles.length) return; + chart.timeScale().setVisibleLogicalRange({ from: from, to: to }); + updateVisibleRangeMarkers(); + }); + }); } function updateVisibleRangeMarkers() { @@ -2374,7 +2427,12 @@ const myToken = ++loadToken; const vKey = viewKey(exKey, sym, tf); const resetView = !!force || vKey !== lastViewKey; - if (resetView) resetChartHistoryState(); + chartDataLoading = true; + if (resetView) { + resetChartHistoryState(); + lastViewKey = ""; + clearChartSeriesData(); + } if (elStatus) { elStatus.className = "market-status"; elStatus.textContent = "加载中…"; @@ -2402,8 +2460,8 @@ applyChartPriceFormat(); } applyCandlesToChart(alignCandlesToTick(data.candles), 0); + lastViewKey = vKey; if (resetView) { - lastViewKey = vKey; applyDefaultVisibleRange(); } syncPosContextForView(exKey, sym); @@ -2443,6 +2501,8 @@ elStatus.className = "market-status err"; elStatus.textContent = String(e.message || e); } + } finally { + if (myToken === loadToken) chartDataLoading = false; } } @@ -2461,6 +2521,7 @@ tfDigitTimer = null; } currentTf = (elTf && elTf.value) || "1d"; + lastViewKey = ""; tickLiveClock(); syncFsToolbarFromMain(); loadChart(false); @@ -2470,6 +2531,7 @@ elExchange.addEventListener("change", function () { updateExchangeDisplay(); syncFsToolbarFromMain(); + lastViewKey = ""; loadChart(false); }); }