diff --git a/web/static/js/app/chart_format.js b/web/static/js/app/chart_format.js index d2b4de5..9cbdc30 100644 --- a/web/static/js/app/chart_format.js +++ b/web/static/js/app/chart_format.js @@ -12,12 +12,8 @@ function updateChartDisplay() { _lastKlinePeriod = curPeriod; // 保存当前的可见范围(周期切换时不保留,避免范围越界) - if (!periodChanged && tvWidget && tvWidget.mainChart) { - try { - window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); - } catch (e) { - window._pendingRestoreView = null; - } + if (!periodChanged && typeof snapshotPendingChartViewport === 'function') { + snapshotPendingChartViewport(); } console.log('更新图表显示'); diff --git a/web/static/js/app/chart_sync.js b/web/static/js/app/chart_sync.js index 892062b..b6d05d5 100644 --- a/web/static/js/app/chart_sync.js +++ b/web/static/js/app/chart_sync.js @@ -113,7 +113,6 @@ function updateTradingViewData(options) { }); const newBarCount = candles.length; - const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0; const firstBarTime = newBarCount > 0 ? candles[0].time : null; const lastBarTime = newBarCount > 0 ? candles[newBarCount - 1].time : null; const clampedVisibleRange = clampVisibleRangeToBarTimes(savedVisibleRange, firstBarTime, lastBarTime); @@ -336,58 +335,23 @@ function updateTradingViewData(options) { ].filter(Boolean); const vr = clampedVisibleRange || savedVisibleRange; - const lr = savedLogicalRange; - const savedScroll = savedScrollPosition; - const mainChartRef = tvWidget.mainChart; + const viewSnap = { + logicalRange: savedLogicalRange, + visibleRange: vr, + scrollPosition: savedScrollPosition + }; const applyPosition = function (tag) { - let ok = false; - // scrollToPosition 最稳;setVisibleRange 的 to 常含右侧空白会锚到最右 - if (typeof savedScroll === 'number' && mainChartRef) { - try { - const pos = savedScroll + (barDelta || 0); - mainChartRef.timeScale().scrollToPosition(pos, false); - const lrNow = mainChartRef.timeScale().getVisibleLogicalRange(); - if (lrNow) { - charts.forEach(c => { - try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {} - }); - ok = true; - console.log('🔄 恢复位置 scroll' + (tag || '') + ':', pos); - } - } catch (e) {} - } - if (!ok && lr && lr.from !== undefined && lr.to !== undefined && newBarCount > 0) { - const span = Math.max(1, lr.to - lr.from); - let to = lr.to; - let from = lr.from; - const maxTo = newBarCount - 1 + 8; - if (to > maxTo) { - to = maxTo; - from = to - span; - } - if (from < -8) { - from = -8; - to = from + span; - } - const clamped = { from: from, to: to }; - charts.forEach(c => { - try { - c.timeScale().setVisibleLogicalRange(clamped); - ok = true; - } catch (e) {} - }); - if (ok) console.log('🔄 恢复位置 logical' + (tag || '') + ':', clamped); - } - if (!ok && vr && vr.from !== undefined && vr.to !== undefined) { - charts.forEach(c => { - try { - c.timeScale().setVisibleRange(vr); - ok = true; - } catch (e) {} - }); - if (ok) console.log('🔄 恢复位置 time' + (tag || '') + ':', vr); - } + if (typeof restoreChartViewState !== 'function') return; + restoreChartViewState(charts, viewSnap, { + incremental: true, + skipBarSpacing: true, + oldBarCount: oldBarCount, + newBarCount: newBarCount, + firstBarTime: firstBarTime, + lastBarTime: lastBarTime + }); + if (tag) console.log('🔄 恢复位置' + tag); }; const finishPreserve = function () { @@ -404,8 +368,18 @@ function updateTradingViewData(options) { applyPosition(''); setTimeout(function () { applyPosition('@0'); }, 0); setTimeout(function () { applyPosition('@50'); }, 50); + // 增量 setData 常不触发可见时间范围回调,但价格轴会变:补刷分型竖边 + var bumpFxVert = function () { + if (typeof window._redrawFxBoxVerticalOverlay === 'function') { + window._redrawFxBoxVerticalOverlay(); + } + }; + bumpFxVert(); + setTimeout(bumpFxVert, 0); + setTimeout(bumpFxVert, 50); setTimeout(function () { applyPosition('@150'); + bumpFxVert(); finishPreserve(); }, 150); } else { diff --git a/web/static/js/app/chart_tv_finalize.js b/web/static/js/app/chart_tv_finalize.js index cdaac8f..6ac0aa2 100644 --- a/web/static/js/app/chart_tv_finalize.js +++ b/web/static/js/app/chart_tv_finalize.js @@ -66,9 +66,7 @@ function chartTvFinalize(ctx) { const allChartsNow = [mainChart, volumeChart, atrChart] .concat(showMacd && macdChart ? [macdChart] : []) .concat(showMacd && chanMacdChart ? [chanMacdChart] : []); - if (hasPendingRestoreView && pendingView) { - restoreChartViewState(allChartsNow, pendingView, { preferTime: true }); - } else { + if (!hasPendingRestoreView || !pendingView) { // 显示最近 200 根K线而非全部挤压(避免K线过多时重叠) if (totalBars > visibleBarsCount) { const rangeFrom = totalBars - visibleBarsCount; @@ -81,8 +79,7 @@ function chartTvFinalize(ctx) { // 立即同步其他图表到主图表的范围(无 pending 时) setTimeout(() => { - if (window._pendingRestoreView) { - restoreChartViewState(allChartsNow, window._pendingRestoreView, { preferTime: true }); + if (pendingView) { return; } const logRange = mainChart.timeScale().getVisibleLogicalRange(); @@ -189,6 +186,9 @@ function chartTvFinalize(ctx) { updateIndicatorPanel(); // 绑定同步事件 + if (hasPendingRestoreView && pendingView) { + window._preserveViewDuringUpdate = true; + } bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd); // 最终确保所有图表时间轴对齐(同时恢复刷新前保存的缩放/位置) @@ -198,13 +198,20 @@ function chartTvFinalize(ctx) { if (showMacd && chanMacdChart) allCharts.push(chanMacdChart); // 检查是否有待恢复的视图(缩放 + 位置) - const pending = window._pendingRestoreView; + const pending = window._pendingRestoreView || pendingView; window._pendingRestoreView = null; if (pending) { - // 恢复刷新前的缩放和位置(时间范围优先,避免数据滑动后逻辑索引错位) + const firstT = candles && candles.length ? candles[0].time : null; + const lastT = candles && candles.length ? candles[candles.length - 1].time : null; console.log('📌 恢复图表视图:', JSON.stringify(pending)); - restoreChartViewState(allCharts, pending, { preferTime: true }); + restoreChartViewState(allCharts, pending, { + firstBarTime: firstT, + lastBarTime: lastT, + oldBarCount: window._preserveViewBarCount || 0, + newBarCount: totalBars + }); + window._preserveViewBarCount = 0; } else { // 无保存视图,正常同步主图到子图 const visibleRange = mainChart.timeScale().getVisibleRange(); @@ -218,6 +225,7 @@ function chartTvFinalize(ctx) { }); } } + window._preserveViewDuringUpdate = false; console.log('🔧 最终时间轴对齐完成'); }, 150); // 只有在时间输入框都为空时才设置图表默认时间范围 diff --git a/web/static/js/app/chart_tv_overlays.js b/web/static/js/app/chart_tv_overlays.js index 222ab3c..be114e9 100644 --- a/web/static/js/app/chart_tv_overlays.js +++ b/web/static/js/app/chart_tv_overlays.js @@ -106,6 +106,32 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) { } mainChartContainer.appendChild(canvas); } + var lastSig = ''; + var watchRaf = null; + var cleaned = false; + var redrawPending = false; + var quant = function (v) { + if (v == null || !isFinite(Number(v))) return 'n'; + return String(Math.round(Number(v))); + }; + // LWC 4 无 priceScale 订阅:采样坐标变化(含增量 setData 后自动缩放) + var sampleSig = function () { + var boxes = window._fxBoxVerticals || []; + var series = getMainPriceSeries(); + if (!series || !boxes.length) return '0'; + var ts = mainChart.timeScale(); + var a = boxes[0]; + var b = boxes[boxes.length - 1]; + return [ + boxes.length, + quant(ts.timeToCoordinate(a.time)), + quant(series.priceToCoordinate(a.hi)), + quant(series.priceToCoordinate(a.lo)), + quant(ts.timeToCoordinate(b.time)), + quant(series.priceToCoordinate(b.hi)), + quant(series.priceToCoordinate(b.lo)) + ].join('|'); + }; var redraw = function () { var boxes = window._fxBoxVerticals || []; var series = getMainPriceSeries(); @@ -119,7 +145,10 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) { if (!ctx2) return; ctx2.setTransform(dpr, 0, 0, dpr, 0, 0); ctx2.clearRect(0, 0, rect.width, rect.height); - if (!series || !boxes.length) return; + if (!series || !boxes.length) { + lastSig = sampleSig(); + return; + } var ts = mainChart.timeScale(); for (var i = 0; i < boxes.length; i++) { var box = boxes[i]; @@ -136,25 +165,48 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) { ctx2.stroke(); } ctx2.setLineDash([]); + lastSig = sampleSig(); }; - var onRange = function () { requestAnimationFrame(redraw); }; - try { mainChart.timeScale().subscribeVisibleLogicalRangeChange(onRange); } catch (e) {} - try { mainChart.timeScale().subscribeVisibleTimeRangeChange(onRange); } catch (e) {} + var scheduleRedraw = function () { + if (cleaned || redrawPending) return; + redrawPending = true; + requestAnimationFrame(function () { + redrawPending = false; + if (!cleaned) redraw(); + }); + }; + var watch = function () { + if (cleaned) return; + watchRaf = requestAnimationFrame(watch); + var sig = sampleSig(); + if (sig !== lastSig) scheduleRedraw(); + }; + try { mainChart.timeScale().subscribeVisibleLogicalRangeChange(scheduleRedraw); } catch (e) {} + try { mainChart.timeScale().subscribeVisibleTimeRangeChange(scheduleRedraw); } catch (e) {} var ro = null; if (typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(onRange); + ro = new ResizeObserver(scheduleRedraw); ro.observe(mainChartContainer); } + window._redrawFxBoxVerticalOverlay = scheduleRedraw; window._fxBoxOverlayCleanup = function () { - try { mainChart.timeScale().unsubscribeVisibleLogicalRangeChange(onRange); } catch (e) {} - try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(onRange); } catch (e) {} + if (cleaned) return; + cleaned = true; + if (watchRaf != null) { + try { cancelAnimationFrame(watchRaf); } catch (e) {} + watchRaf = null; + } + window._redrawFxBoxVerticalOverlay = null; + try { mainChart.timeScale().unsubscribeVisibleLogicalRangeChange(scheduleRedraw); } catch (e) {} + try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(scheduleRedraw); } catch (e) {} if (ro) try { ro.disconnect(); } catch (e) {} try { if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas); } catch (e) {} }; if (!window._tvInitCleanups) window._tvInitCleanups = []; window._tvInitCleanups.push(window._fxBoxOverlayCleanup); - requestAnimationFrame(redraw); - setTimeout(redraw, 50); + scheduleRedraw(); + setTimeout(scheduleRedraw, 50); + watchRaf = requestAnimationFrame(watch); } function chartTvRenderOverlays(ctx) { @@ -2137,28 +2189,11 @@ function chartTvRenderOverlays(ctx) { }); safeOverlayLineSetData(bottomSeries, [{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); - const leftSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - safeOverlayLineSetData(leftSeries, [{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]); - - const rightSeries = mainChart.addLineSeries({ - color: boxColor, - lineWidth: 1, - lineStyle: 2, - lastValueVisible: false, - priceLineVisible: false, - crosshairMarkerVisible: false, - }); - safeOverlayLineSetData(rightSeries, [{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]); + pushFxBoxVertical(startTs, boxLow, boxHigh, boxColor); + pushFxBoxVertical(endTs, boxLow, boxHigh, boxColor); if (!tvWidget.series.subSubKlcFxBoxSeries) tvWidget.series.subSubKlcFxBoxSeries = []; - tvWidget.series.subSubKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries); + tvWidget.series.subSubKlcFxBoxSeries.push(topSeries, bottomSeries); } } } catch (e) { console.error('绘制次次周期KLC分型标记出错:', e); } @@ -2461,4 +2496,11 @@ function chartTvRenderOverlays(ctx) { } } } + + // KLC 分型框竖边:canvas 真竖线(LWC 折线做不到不斜) + try { + syncFxBoxVerticalOverlay(mainChart, mainChartContainer); + } catch (e) { + console.warn('分型竖边 overlay 失败:', e); + } } diff --git a/web/static/js/app/chart_view.js b/web/static/js/app/chart_view.js index 8b4f890..0491e58 100644 --- a/web/static/js/app/chart_view.js +++ b/web/static/js/app/chart_view.js @@ -1,4 +1,4 @@ -/* chart_view.js — split from chart.js */ +/* chart_view.js — 手动分析 / 自动刷新 两套独立拉数逻辑 */ /** 用尾部 N 根合并进已有 K 线(同 timestamp 覆盖,更新则追加) */ function mergeKlineTail(existing, incoming) { @@ -41,12 +41,29 @@ function mergeKlineTail(existing, incoming) { return out; } -function updateChart(options) { - options = options || {}; - // 只显示旋转加载图标 - $('#refreshLoadingSpinner').show(); - - // 获取参数 +/** 实时基线是否过旧(仅自动刷新用来决定 recent vs 全量 live) */ +function isLiveBaselineStale(timeframe) { + if (!currentData || !Array.isArray(currentData.kline_data) || !currentData.kline_data.length) { + return true; + } + const last = currentData.kline_data[currentData.kline_data.length - 1]; + let lastMs; + if (last.timestamp != null && last.timestamp !== '') { + lastMs = Number(last.timestamp); + } else { + lastMs = new Date(last.date).getTime(); + } + if (Number.isNaN(lastMs)) return true; + const tfMs = (window.timeframeToMs && window.timeframeToMs(timeframe)) || (15 * 60 * 1000); + return (Date.now() - lastMs) > tfMs * 3; +} + +/** 兼容旧名 */ +function isChartBaselineStale(timeframe) { + return isLiveBaselineStale(timeframe); +} + +function readChartFormContext() { const dataSource = $('#dataSource').val() || 'crypto'; let symbol; if (dataSource === 'crypto') { @@ -54,91 +71,194 @@ function updateChart(options) { } else { symbol = $('#astockSymbol').val() || '000001'; } - - const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h'; - const timezone = $('#timezone').val() || 'Asia/Shanghai'; - const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m'; - const subSubTimeframe = $('#subSubTimeframe').val() || ''; - - // 确保时区参数有效 - console.log('更新图表使用时区:', timezone, 'reason:', options.reason || (options.fromAutoRefresh ? 'auto' : 'manual')); - console.log('数据源:', dataSource, '交易对/股票:', symbol); - - // 如果symbol为空,不发送请求 - if (!symbol) { + return { + dataSource: dataSource, + symbol: symbol, + timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h', + timezone: $('#timezone').val() || 'Asia/Shanghai', + elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m', + subSubTimeframe: $('#subSubTimeframe').val() || '', + startTimeMs: $('#start_time').val() ? new Date($('#start_time').val()).getTime() : null, + endTimeMs: $('#end_time').val() ? new Date($('#end_time').val()).getTime() : null + }; +} + +/** 当前展示用 K 线序列根数(主/小/次次周期与 freeze 逻辑一致) */ +function getActiveKlineBarCount(data) { + data = data || (typeof currentData !== 'undefined' ? currentData : null); + if (!data) return 0; + const series = ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) || + ($('#elementPeriodKline').is(':checked') && data.element_kline_data) || + data.kline_data; + return Array.isArray(series) ? series.length : 0; +} + +/** 全量 init 前拍快照(周期切换等;不含 _preserveViewOnRefresh) */ +function snapshotPendingChartViewport() { + if (!tvWidget || !tvWidget.mainChart || typeof captureChartViewState !== 'function') { + return; + } + try { + window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); + window._preserveViewBarCount = getActiveKlineBarCount(); + } catch (e) { + window._pendingRestoreView = null; + window._preserveViewBarCount = 0; + } +} + +function freezeChartViewportBeforeRequest() { + try { + if (tvWidget && tvWidget.mainChart) { + const snap = captureChartViewState(tvWidget.mainChart); + window._preserveViewOnRefresh = snap; + // 全量 initTradingView 只认 _pendingRestoreView,须与增量冻结同步 + window._pendingRestoreView = snap; + window._preserveViewBarCount = getActiveKlineBarCount(); + console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, snap); + } + } catch (e) { + window._preserveViewOnRefresh = null; + window._pendingRestoreView = null; + window._preserveViewBarCount = 0; + } +} + +function abortInFlightChartRequest() { + if (window._analyzeXhr && window._analyzeXhr.readyState !== 4) { + try { window._analyzeXhr.abort(); } catch (e) {} + } +} + +function applyAnalyzeSuccess(data, symbol, options) { + options = options || {}; + const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || ''; + if (currentData) { + delete currentData.original_kline_data; + delete currentData.original_macd; + } + currentData = data; + window._lastChartSymbol = symbol; + window._lastFullAnalyzeAt = Date.now(); + if (typeof renderWyckoffCycleSummary === 'function') { + renderWyckoffCycleSummary(); + } + + const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); + const structureZonesOn = $('#showMainStructureZone').is(':checked'); + const symbolChanged = !!(prevSymbol && prevSymbol !== symbol); + let wantIncremental = options.incremental !== undefined ? !!options.incremental : ready; + if (structureZonesOn || options.forceFullRebuild || symbolChanged || options.incremental === false) { + wantIncremental = false; + } + refreshChart(data, { incremental: wantIncremental }); +} + +/** + * 手动分析(按钮 / 首屏 / 切换参数) + * - 严格使用表单 start_time / end_time + * - 只走 /api/analyze,不做 recent 合并,不改结束时间为现在 + */ +function analyzeChart(options) { + options = options || {}; + $('#refreshLoadingSpinner').show(); + + const ctx = readChartFormContext(); + console.log('手动分析:', ctx.symbol, ctx.timeframe, 'range', ctx.startTimeMs, '→', ctx.endTimeMs, options.reason || ''); + + if (!ctx.symbol) { console.error('交易对/股票代码不能为空'); $('#refreshLoadingSpinner').hide(); return; } - - console.log(`更新图表: symbol=${symbol}, timeframe=${timeframe}, elementTimeframe=${elementTimeframe}, timezone=${timezone}`); - - // 获取开始和结束时间(如果已设置) - let startTimeMs = null; - let endTimeMs = null; - - if ($('#start_time').val()) { - startTimeMs = new Date($('#start_time').val()).getTime(); - } - - if ($('#end_time').val()) { - endTimeMs = new Date($('#end_time').val()).getTime(); - } - - // 自动刷新:取消进行中的上一请求,避免响应堆积 - if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) { - try { window._analyzeXhr.abort(); } catch (e) {} - } - - // 请求发出前冻结视窗(与自动刷新同一套;避免等响应时/setData 后 logical 索引漂移) - try { - if (tvWidget && tvWidget.mainChart) { - window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart); - const prev = currentData && ( - ($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) || - ($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) || - currentData.kline_data - ); - window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0; - console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, window._preserveViewOnRefresh); - } - } catch (e) { - window._preserveViewOnRefresh = null; - window._preserveViewBarCount = 0; - } + abortInFlightChartRequest(); + freezeChartViewportBeforeRequest(); const requestId = ++lastRequestId; + + window._analyzeXhr = $.ajax({ + url: '/api/analyze', + data: { + symbol: ctx.symbol, + timeframe: ctx.timeframe, + timezone: ctx.timezone, + element_timeframe: ctx.elementTimeframe, + sub_sub_timeframe: ctx.subSubTimeframe || undefined, + start_time: ctx.startTimeMs, + end_time: ctx.endTimeMs, + elements_only: false, + zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000, + include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0 + }, + success: function(data) { + $('#refreshLoadingSpinner').hide(); + if (requestId !== lastRequestId) return; + applyAnalyzeSuccess(data, ctx.symbol, { + incremental: options.incremental !== undefined ? options.incremental : false, + forceFullRebuild: true + }); + }, + error: function(jqXHR, textStatus, errorThrown) { + $('#refreshLoadingSpinner').hide(); + if (textStatus === 'abort') return; + console.error('分析失败:', errorThrown); + alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown)); + } + }); +} + +/** + * 自动刷新(仅启用自动刷新时) + * - 结束时间固定为当前时间(调用方先 updateEndTimeToNow) + * - mode=recent:/api/klines/recent 增量合并(不重算缠论) + * - mode=full:/api/analyze 全量 live(start 用表单,end=现在) + */ +function autoRefreshChart(options) { + options = options || {}; + const mode = options.mode === 'full' ? 'full' : 'recent'; + $('#refreshLoadingSpinner').show(); + + const ctx = readChartFormContext(); + console.log('自动刷新:', mode, ctx.symbol, ctx.timeframe, 'end=', ctx.endTimeMs); + + if (!ctx.symbol) { + $('#refreshLoadingSpinner').hide(); + return; + } + + abortInFlightChartRequest(); + freezeChartViewportBeforeRequest(); + const requestId = ++lastRequestId; + const chartsReady = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); const hasBaseline = !!(currentData && Array.isArray(currentData.kline_data) && currentData.kline_data.length); const baselineSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || ''; - // 自动刷新常态:只拉最近 2 根;换币对后基线不一致则禁止尾部合并(否则会叠旧缠论) - // fullAnalyze(约每 1 分钟)走全量 analyze 更新缠论 - const useRecentTail = !!( - options.fromAutoRefresh && - !options.fullAnalyze && + const canRecent = !!( + mode === 'recent' && chartsReady && hasBaseline && baselineSymbol && - baselineSymbol === symbol + baselineSymbol === ctx.symbol && + !isLiveBaselineStale(ctx.timeframe) ); - if (useRecentTail) { + if (canRecent) { console.log('自动刷新 → /api/klines/recent limit=2'); window._analyzeXhr = $.ajax({ url: '/api/klines/recent', data: { - symbol: symbol, - timeframe: timeframe, + symbol: ctx.symbol, + timeframe: ctx.timeframe, limit: 2, - element_timeframe: elementTimeframe || undefined, - sub_sub_timeframe: subSubTimeframe || undefined + element_timeframe: ctx.elementTimeframe || undefined, + sub_sub_timeframe: ctx.subSubTimeframe || undefined }, success: function(partial) { $('#refreshLoadingSpinner').hide(); if (requestId !== lastRequestId) return; if (!partial || !Array.isArray(partial.kline_data)) { - console.warn('recent 响应无效,回退全量 analyze'); - updateChart({ incremental: true, reason: 'recent-fallback' }); + console.warn('recent 无效,改走 live 全量'); + autoRefreshChart({ mode: 'full', reason: 'recent-fallback' }); return; } currentData.kline_data = mergeKlineTail(currentData.kline_data, partial.kline_data); @@ -163,81 +283,60 @@ function updateChart(options) { error: function(jqXHR, textStatus, errorThrown) { $('#refreshLoadingSpinner').hide(); if (textStatus === 'abort') return; - console.warn('recent 失败,回退全量 analyze:', errorThrown); - updateChart({ incremental: true, reason: 'recent-error-fallback' }); + console.warn('recent 失败,改走 live 全量:', errorThrown); + autoRefreshChart({ mode: 'full', reason: 'recent-error-fallback' }); } }); return; } - - // 手动 / 首拉:全量 analyze + + console.log('自动刷新 → /api/analyze (live)'); window._analyzeXhr = $.ajax({ url: '/api/analyze', data: { - symbol: symbol, - timeframe: timeframe, - timezone: timezone, - element_timeframe: elementTimeframe, - sub_sub_timeframe: subSubTimeframe || undefined, - start_time: startTimeMs, - end_time: endTimeMs, + symbol: ctx.symbol, + timeframe: ctx.timeframe, + timezone: ctx.timezone, + element_timeframe: ctx.elementTimeframe, + sub_sub_timeframe: ctx.subSubTimeframe || undefined, + start_time: ctx.startTimeMs, + end_time: ctx.endTimeMs, elements_only: false, zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000, include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0 - // 威科夫随主分析一并返回;开关仅控制绘制,不再传 include_wyckoff }, success: function(data) { - // 隐藏加载图标 $('#refreshLoadingSpinner').hide(); - - // 忽略过期响应 - if (requestId !== lastRequestId) { - return; - } - - // 保存当前数据 - const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || ''; - if (currentData) { - // 覆盖前断开旧引用,帮助GC尽快回收 - delete currentData.original_kline_data; - delete currentData.original_macd; - } - currentData = data; - window._lastChartSymbol = symbol; - window._lastFullAnalyzeAt = Date.now(); - if (typeof renderWyckoffCycleSummary === 'function') { - renderWyckoffCycleSummary(); - } - - // 有图则增量;笔/段/中枢/结构区只在全量 init 绘制 - // 换币对 / 手动分析 / 结构区:必须全量重建,否则会残留旧币对叠层 - const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); - const structureZonesOn = $('#showMainStructureZone').is(':checked'); - const symbolChanged = !!(prevSymbol && prevSymbol !== symbol); - let wantIncremental = options.incremental !== undefined - ? !!options.incremental - : (ready || !!options.fromAutoRefresh); - if (structureZonesOn || options.fullAnalyze || symbolChanged || options.incremental === false) { - wantIncremental = false; - } - refreshChart(data, { incremental: wantIncremental }); + if (requestId !== lastRequestId) return; + applyAnalyzeSuccess(data, ctx.symbol, { + incremental: options.incremental === true, + forceFullRebuild: options.forceFullRebuild !== false && options.incremental !== true + }); }, error: function(jqXHR, textStatus, errorThrown) { - // 隐藏加载图标 $('#refreshLoadingSpinner').hide(); - if (textStatus === 'abort') { - return; - } - - // 显示错误信息 - console.error('加载数据失败:', errorThrown); - // 自动刷新失败不弹窗打扰 - if (!options.fromAutoRefresh) { - alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown)); - } + if (textStatus === 'abort') return; + console.error('自动刷新分析失败:', errorThrown); } }); } + +/** 兼容旧调用:手动走 analyzeChart;fromAutoRefresh 转 autoRefreshChart */ +function updateChart(options) { + options = options || {}; + if (options.fromAutoRefresh) { + console.warn('updateChart(fromAutoRefresh) 已废弃,请改用 autoRefreshChart'); + autoRefreshChart({ + mode: options.fullAnalyze ? 'full' : 'recent', + incremental: options.incremental, + forceFullRebuild: options.incremental === false, + reason: options.reason + }); + return; + } + analyzeChart(options); +} + function captureChartViewState(chart) { if (!chart || !chart.timeScale) return null; const ts = chart.timeScale(); @@ -287,77 +386,102 @@ function applySeriesDataTail(series, points, tailOnly) { } function restoreChartViewState(charts, viewState, options) { - // 全量重建备用:先缩放,再位置;不要在位置前写 rightOffset(会右边缘锚定) if (!viewState || !Array.isArray(charts) || charts.length === 0) return; options = options || {}; - const preferTime = !!options.preferTime; const validCharts = charts.filter(c => c && c.timeScale); if (validCharts.length === 0) return; const mainChart = validCharts[0]; + const incremental = !!options.incremental; - validCharts.forEach(c => { - try { - if (typeof viewState.barSpacing === 'number') { - c.timeScale().applyOptions({ barSpacing: viewState.barSpacing }); + const oldBarCount = options.oldBarCount || window._preserveViewBarCount || 0; + const newBarCount = options.newBarCount || 0; + const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0; + + const applyLogical = function (lr) { + if (!lr || lr.from === undefined || lr.to === undefined) return false; + let from = lr.from; + let to = lr.to; + if (newBarCount > 0) { + const span = Math.max(1, to - from); + const maxTo = newBarCount - 1 + 8; + if (to > maxTo) { + to = maxTo; + from = to - span; } - } catch (e) {} - }); + if (from < -8) { + from = -8; + to = from + span; + } + lr = { from: from, to: to }; + } + let ok = false; + validCharts.forEach(c => { + try { + c.timeScale().setVisibleLogicalRange(lr); + ok = true; + } catch (e) {} + }); + return ok; + }; - let restored = false; + const applyVisible = function () { + if (!viewState.visibleRange || + viewState.visibleRange.from === undefined || + viewState.visibleRange.to === undefined) { + return false; + } + let vr = viewState.visibleRange; + if (options.firstBarTime != null && options.lastBarTime != null) { + vr = clampVisibleRangeToBarTimes(vr, options.firstBarTime, options.lastBarTime); + } + let ok = false; + validCharts.forEach(c => { + try { + c.timeScale().setVisibleRange(vr); + ok = true; + } catch (e) {} + }); + return ok; + }; - const syncFromMain = function () { + const applyScroll = function () { + if (typeof viewState.scrollPosition !== 'number') return false; try { + const pos = viewState.scrollPosition + barDelta; + mainChart.timeScale().scrollToPosition(pos, false); const lrNow = mainChart.timeScale().getVisibleLogicalRange(); if (lrNow) { validCharts.forEach(c => { try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {} }); - restored = true; + return true; } } catch (e) {} + return false; }; - if (typeof viewState.scrollPosition === 'number') { - try { - mainChart.timeScale().scrollToPosition(viewState.scrollPosition, false); - syncFromMain(); - } catch (e) {} - } - - const tryVisibleRange = function () { - if (!viewState.visibleRange || viewState.visibleRange.from === undefined || viewState.visibleRange.to === undefined) { - return false; + const restorePosition = function () { + if (incremental) { + // 尾部增量:scroll+barDelta 最稳;全量重建勿先 scroll(中间段会锚到最右) + if (applyScroll()) return true; + if (applyLogical(viewState.logicalRange)) return true; + return applyVisible(); } + // 全量重建:logical → clamped time → scroll + if (applyLogical(viewState.logicalRange)) return true; + if (applyVisible()) return true; + return applyScroll(); + }; + + restorePosition(); + + // barSpacing 写在位置之后会按右缘重锚,故放最后并再扳一次位置 + if (!options.skipBarSpacing && typeof viewState.barSpacing === 'number') { validCharts.forEach(c => { try { - c.timeScale().setVisibleRange(viewState.visibleRange); - restored = true; + c.timeScale().applyOptions({ barSpacing: viewState.barSpacing }); } catch (e) {} }); - return restored; - }; - - const tryLogicalRange = function () { - if (!viewState.logicalRange || viewState.logicalRange.from === undefined || viewState.logicalRange.to === undefined) { - return false; - } - validCharts.forEach(c => { - try { - c.timeScale().setVisibleLogicalRange(viewState.logicalRange); - restored = true; - } catch (e) {} - }); - return restored; - }; - - if (!restored) { - if (preferTime) { - tryVisibleRange(); - if (!restored) tryLogicalRange(); - } else { - tryLogicalRange(); - if (!restored) tryVisibleRange(); - } + restorePosition(); } } -// 初始化图表 diff --git a/web/static/js/app/macd_ui.js b/web/static/js/app/macd_ui.js index 6f94d4b..a30f244 100644 --- a/web/static/js/app/macd_ui.js +++ b/web/static/js/app/macd_ui.js @@ -37,7 +37,7 @@ function saveMacdConfig() { data: JSON.stringify({ fast: fast, slow: slow, signal: signal }), success: function() { hideMacdConfig(); - updateChart(); + analyzeChart({ reason: 'macd-config-saved' }); }, error: function() { alert('保存MACD参数失败'); @@ -87,7 +87,7 @@ $(document).on('change', '#showMainStructureZone', function() { console.log('结构区切换为:', on); // 勾选后才向服务器请求多周期结构区数据;结构区叠层只在全量 init 里绘制,必须 incremental:false if (on) { - updateChart({ incremental: false }); + analyzeChart({ incremental: false, reason: 'structure-zone-on' }); } else { updateChartDisplay(); } diff --git a/web/static/js/app/ui.js b/web/static/js/app/ui.js index fb6d4f9..a91ba19 100644 --- a/web/static/js/app/ui.js +++ b/web/static/js/app/ui.js @@ -339,7 +339,7 @@ $(document).ready(function() { startAStockStatusUpdater(); // A 股:metadata 完成后再拉数(下方不再重复 updateChart) setTimeout(function() { - updateChart(); + analyzeChart({ reason: 'astock-init' }); }, 300); }); } @@ -378,10 +378,10 @@ $(document).ready(function() { // 尝试加载更多交易对 loadSymbols(); - // 初始化图表:默认加密货币延迟拉取;若首屏为 A 股则在 chart_metadata 完成后再 updateChart + // 初始化图表:默认加密货币延迟拉取;若首屏为 A 股则在 chart_metadata 完成后再 analyze if (initialDataSource !== 'a_stock') { setTimeout(function() { - updateChart(); + analyzeChart({ reason: 'crypto-init' }); }, 500); } @@ -533,30 +533,35 @@ function startAutoRefresh() { // 启动定时器 autoRefreshTick = 0; + + // 进入实时模式:结束时间=现在,立刻走自动刷新全量(视窗由 autoRefreshChart 内 freeze 冻结) + updateEndTimeToNow(); + autoRefreshChart({ + mode: 'full', + incremental: false, + forceFullRebuild: true, + reason: 'auto-refresh-start' + }); + autoRefreshTimer = setInterval(function() { - // 更新结束时间显示(仅 UI) + // 自动刷新专用:结束时间推进到现在 updateEndTimeToNow(); autoRefreshTick += 1; const now = Date.now(); const lastFull = window._lastFullAnalyzeAt || 0; - const needFullAnalyze = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS); - // 常态:/api/klines/recent 合并尾部 K;满 1 分钟:全量 /api/analyze 刷新缠论 - if (needFullAnalyze) { - console.log('自动刷新 → 全量缠论 analyze(距上次', lastFull ? Math.round((now - lastFull) / 1000) + 's' : '首次', ')'); - updateChart({ - fromAutoRefresh: true, - fullAnalyze: true, - incremental: true - }); + const tf = $('#timeframe').val() || '4h'; + const needFull = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS) || + (typeof isLiveBaselineStale === 'function' && isLiveBaselineStale(tf)); + + if (needFull) { + console.log('自动刷新 tick → live 全量'); + autoRefreshChart({ mode: 'full', incremental: false, forceFullRebuild: true }); } else { - updateChart({ - fromAutoRefresh: true, - incremental: true - }); + console.log('自动刷新 tick → recent 尾部'); + autoRefreshChart({ mode: 'recent' }); } - // 更新下次刷新时间 nextRefreshTime = new Date(Date.now() + intervalMs); updateNextRefreshTimeDisplay(); }, intervalMs); @@ -798,21 +803,7 @@ function refreshChart(data, options) { // 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts if (preferIncremental && chartsReady) { try { - // 数据到达后再冻结视窗(比请求发出时更准;避免用到过期 scroll) - if (tvWidget.mainChart && typeof captureChartViewState === 'function') { - try { - window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart); - const prev = currentData && ( - ($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) || - ($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) || - currentData.kline_data - ); - window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0; - } catch (e) { - window._preserveViewOnRefresh = null; - window._preserveViewBarCount = 0; - } - } + // 视窗已在请求发出时 freezeChartViewportBeforeRequest 冻结,勿在此重拍(会弄错 barCount) updateTradingViewData({ tailOnly: !!options.skipTables }); // recent-tail 刷新结构未变,跳过表格重绘以提速 if (!options.skipTables) { @@ -829,7 +820,11 @@ function refreshChart(data, options) { // 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window // tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上 - if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) { + // 全量重建:优先用请求前冻结的视窗(分析按钮在请求发出时已 capture) + if (window._preserveViewOnRefresh) { + window._pendingRestoreView = window._preserveViewOnRefresh; + console.log('📌 全量重建:使用请求前冻结视窗'); + } else if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) { try { window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView)); diff --git a/web/templates/index.html b/web/templates/index.html index 4dc2ab0..d6c762e 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -982,7 +982,7 @@