From c2203abfa85c907c4a283cf19e2d04ed5cf6d704 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 08:06:57 +0800 Subject: [PATCH] Preserve chart zoom and pan across tail refresh updates. Keep the user viewport when SSE updates candles so zooming out to see the full series is not reset to the recent 200-bar view. Co-authored-by: Cursor --- manual_trading_hub/static/chart.js | 69 ++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 16e1e38..27618b0 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -175,6 +175,9 @@ let chartViewEpoch = 0; let rangeUiTimer = null; let loadOlderTimer = null; + let chartRangeUserLocked = false; + let chartRangeLockTimer = null; + let suppressRangeUserLock = false; let priceTagTimer = null; let tfDigitBuf = ""; let tfDigitTimer = null; @@ -1950,6 +1953,9 @@ }); chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) { + if (!chartDataLoading && range && !suppressRangeUserLock) { + markChartRangeUserAdjusted(); + } scheduleRangeUiUpdate(); if ( !range || @@ -2025,6 +2031,23 @@ return range.to >= maxTo - 24; } + function markChartRangeUserAdjusted() { + chartRangeUserLocked = true; + if (chartRangeLockTimer) clearTimeout(chartRangeLockTimer); + chartRangeLockTimer = setTimeout(function () { + chartRangeLockTimer = null; + chartRangeUserLocked = false; + }, 30000); + } + + function restoreVisibleLogicalRange(range, candleCount) { + if (!chart || !range || !isVisibleRangeValidForCandles(range, candleCount)) return false; + suppressRangeUserLock = true; + chart.timeScale().setVisibleLogicalRange(range); + suppressRangeUserLock = false; + return true; + } + function shouldLoadOlderOnRange(range) { if (!range || !lastCandles.length) return false; const n = lastCandles.length; @@ -2100,7 +2123,12 @@ return merged; } - function applyCandlesToChart(candles, rangeShift) { + function applyCandlesToChart(candles, rangeShift, opts) { + opts = opts || {}; + let savedRange = null; + if (opts.preserveRange && chart) { + savedRange = chart.timeScale().getVisibleLogicalRange(); + } lastCandles = alignCandlesToTick(candles); indexCandles(lastCandles); candleSeries.setData(lastCandles); @@ -2109,13 +2137,19 @@ if (rangeShift && chart) { const range = chart.timeScale().getVisibleLogicalRange(); if (range) { + suppressRangeUserLock = true; chart.timeScale().setVisibleLogicalRange({ from: range.from + rangeShift, to: range.to + rangeShift, }); + suppressRangeUserLock = false; } + } else if (savedRange) { + restoreVisibleLogicalRange(savedRange, lastCandles.length); + } + if (!opts.skipAutoScale) { + applyPriceAutoScale(); } - applyPriceAutoScale(); updateVisibleRangeMarkers(); try { updateIndicators(); @@ -2194,8 +2228,6 @@ const candleCountBefore = lastCandles.length; let savedRange = null; if (chart) savedRange = chart.timeScale().getVisibleLogicalRange(); - const wasViewingTail = - !savedRange || isViewingChartTail(savedRange, candleCountBefore); try { const data = await fetchChartChunk({ exchange_key: exKey, @@ -2216,20 +2248,18 @@ applyChartPriceFormat(); } } - applyCandlesToChart(mergeCandles(lastCandles, alignCandlesToTick(data.candles), { prepend: false }), 0); + applyCandlesToChart( + mergeCandles(lastCandles, alignCandlesToTick(data.candles), { prepend: false }), + 0, + { preserveRange: true, skipAutoScale: chartRangeUserLocked } + ); if (epochAtStart !== chartViewEpoch) return; const n = lastCandles.length; - const curRange = chart && chart.timeScale().getVisibleLogicalRange(); - const minorTailUpdate = Math.abs(n - candleCountBefore) <= 5; - if ( - savedRange && - isVisibleRangeValidForCandles(savedRange, n) && - (minorTailUpdate || !wasViewingTail) - ) { - chart.timeScale().setVisibleLogicalRange(savedRange); - } else if (!isVisibleRangeValidForCandles(curRange, n)) { - const tailRange = tailVisibleLogicalRange(n); - if (tailRange) chart.timeScale().setVisibleLogicalRange(tailRange); + if (!restoreVisibleLogicalRange(savedRange, n)) { + const curRange = chart && chart.timeScale().getVisibleLogicalRange(); + if (!chartRangeUserLocked && curRange && !isVisibleRangeValidForCandles(curRange, n)) { + restoreVisibleLogicalRange(tailVisibleLogicalRange(n), n); + } } scheduleRangeUiUpdate(); if (posContext) { @@ -2258,7 +2288,7 @@ const r = tailVisibleLogicalRange(lastCandles.length); if (!r) return; applyChartRightGap(); - chart.timeScale().setVisibleLogicalRange(r); + restoreVisibleLogicalRange(r, lastCandles.length); updateVisibleRangeMarkers(); } applyOnce(); @@ -2482,6 +2512,11 @@ chartDataLoading = true; if (resetView) { chartViewEpoch += 1; + chartRangeUserLocked = false; + if (chartRangeLockTimer) { + clearTimeout(chartRangeLockTimer); + chartRangeLockTimer = null; + } resetChartHistoryState(); lastViewKey = ""; clearChartSeriesData();