fix(web): 分析/自动刷新后保留 K 线视窗位置

拆分手动分析与自动刷新拉数路径;全量重建用 logical 优先恢复视窗,
增量 recent 用 scroll+barDelta;避免 barSpacing 重锚与重复冻结导致往右跳。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 23:38:20 +08:00
co-authored by Cursor
parent 8ee11317d3
commit 90499533fb
8 changed files with 449 additions and 310 deletions
+296 -172
View File
@@ -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 全量 livestart 用表单,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);
}
});
}
/** 兼容旧调用:手动走 analyzeChartfromAutoRefresh 转 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();
}
}
// 初始化图表