fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新

自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+227 -29
View File
@@ -1,4 +1,46 @@
/* chart_view.js — split from chart.js */
/** 用尾部 N 根合并进已有 K 线(同 timestamp 覆盖,更新则追加) */
function mergeKlineTail(existing, incoming) {
if (!Array.isArray(incoming) || !incoming.length) {
return Array.isArray(existing) ? existing : [];
}
if (!Array.isArray(existing) || !existing.length) {
return incoming.slice();
}
const out = existing.slice();
const barTs = (row) => {
if (row && row.timestamp != null && row.timestamp !== '') {
const n = Number(row.timestamp);
if (!Number.isNaN(n)) return n;
}
const t = row && row.date != null ? new Date(row.date).getTime() : NaN;
return Number.isNaN(t) ? null : t;
};
for (let i = 0; i < incoming.length; i++) {
const row = incoming[i];
const ts = barTs(row);
if (ts == null) continue;
let idx = -1;
const scanFrom = Math.max(0, out.length - 8);
for (let j = out.length - 1; j >= scanFrom; j--) {
if (barTs(out[j]) === ts) {
idx = j;
break;
}
}
if (idx >= 0) {
out[idx] = Object.assign({}, out[idx], row);
} else {
const lastTs = barTs(out[out.length - 1]);
if (lastTs == null || ts > lastTs) {
out.push(row);
}
}
}
return out;
}
function updateChart(options) {
options = options || {};
// 只显示旋转加载图标
@@ -13,7 +55,7 @@ function updateChart(options) {
symbol = $('#astockSymbol').val() || '000001';
}
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m';
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() || '';
@@ -47,9 +89,88 @@ function updateChart(options) {
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;
}
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 &&
chartsReady &&
hasBaseline &&
baselineSymbol &&
baselineSymbol === symbol
);
if (useRecentTail) {
console.log('自动刷新 → /api/klines/recent limit=2');
window._analyzeXhr = $.ajax({
url: '/api/klines/recent',
data: {
symbol: symbol,
timeframe: timeframe,
limit: 2,
element_timeframe: elementTimeframe || undefined,
sub_sub_timeframe: 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' });
return;
}
currentData.kline_data = mergeKlineTail(currentData.kline_data, partial.kline_data);
if (Array.isArray(partial.element_kline_data)) {
currentData.element_kline_data = mergeKlineTail(
currentData.element_kline_data, partial.element_kline_data
);
if (partial.element_timeframe) {
currentData.element_timeframe = partial.element_timeframe;
}
}
if (Array.isArray(partial.sub_sub_kline_data)) {
currentData.sub_sub_kline_data = mergeKlineTail(
currentData.sub_sub_kline_data, partial.sub_sub_kline_data
);
if (partial.sub_sub_timeframe) {
currentData.sub_sub_timeframe = partial.sub_sub_timeframe;
}
}
refreshChart(currentData, { incremental: true, skipTables: true });
},
error: function(jqXHR, textStatus, errorThrown) {
$('#refreshLoadingSpinner').hide();
if (textStatus === 'abort') return;
console.warn('recent 失败,回退全量 analyze:', errorThrown);
updateChart({ incremental: true, reason: 'recent-error-fallback' });
}
});
return;
}
// 发送请求
const requestId = ++lastRequestId; // 标记本次请求
// 手动 / 首拉:全量 analyze
window._analyzeXhr = $.ajax({
url: '/api/analyze',
data: {
@@ -62,8 +183,8 @@ function updateChart(options) {
end_time: endTimeMs,
elements_only: false,
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
// 威科夫随主分析一并返回;开关仅控制绘制,不再传 include_wyckoff
},
success: function(data) {
// 隐藏加载图标
@@ -75,18 +196,31 @@ function updateChart(options) {
}
// 保存当前数据
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();
}
refreshChart(data, {
incremental: options.incremental !== undefined
? !!options.incremental
: !!options.fromAutoRefresh
});
// 有图则增量;笔/段/中枢/结构区只在全量 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 });
},
error: function(jqXHR, textStatus, errorThrown) {
// 隐藏加载图标
@@ -117,49 +251,113 @@ function captureChartViewState(chart) {
};
}
function restoreChartViewState(charts, viewState) {
/** 将可见时间窗口限制在真实 K 线范围内,避免 to 落在右侧空白区导致锚到最右 */
function clampVisibleRangeToBarTimes(vr, firstTime, lastTime) {
if (!vr || vr.from === undefined || vr.to === undefined) return vr;
if (firstTime == null || lastTime == null) return vr;
const f = Number(firstTime);
const l = Number(lastTime);
if (!isFinite(f) || !isFinite(l)) return vr;
let from = Number(vr.from);
let to = Number(vr.to);
const span = Math.max(1, to - from);
if (to > l) {
to = l;
from = to - span;
}
if (from < f) {
from = f;
to = from + span;
}
return { from: from, to: to };
}
/** 尾部合并时用 update 代替 setData,避免 LWC 重置滚动位置 */
function applySeriesDataTail(series, points, tailOnly) {
if (!series || typeof series.setData !== 'function' || !Array.isArray(points) || !points.length) {
return;
}
if (tailOnly && typeof series.update === 'function' && points.length > 2) {
points.slice(-4).forEach(function (p) {
try { series.update(p); } catch (e) {}
});
return;
}
series.setData(points);
}
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];
validCharts.forEach(c => {
try {
const optionsPatch = {};
if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing;
if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset;
if (Object.keys(optionsPatch).length) {
c.timeScale().applyOptions(optionsPatch);
if (typeof viewState.barSpacing === 'number') {
c.timeScale().applyOptions({ barSpacing: viewState.barSpacing });
}
} catch (e) {}
});
let restored = false;
// 优先按逻辑范围恢复(对新数据更稳健)
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
validCharts.forEach(c => {
try {
c.timeScale().setVisibleLogicalRange(viewState.logicalRange);
const syncFromMain = function () {
try {
const lrNow = mainChart.timeScale().getVisibleLogicalRange();
if (lrNow) {
validCharts.forEach(c => {
try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {}
});
restored = true;
} catch (e) {}
});
}
} catch (e) {}
};
if (typeof viewState.scrollPosition === 'number') {
try {
mainChart.timeScale().scrollToPosition(viewState.scrollPosition, false);
syncFromMain();
} catch (e) {}
}
// 逻辑范围失败时,回退到时间可见范围
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
const tryVisibleRange = function () {
if (!viewState.visibleRange || viewState.visibleRange.from === undefined || viewState.visibleRange.to === undefined) {
return false;
}
validCharts.forEach(c => {
try {
c.timeScale().setVisibleRange(viewState.visibleRange);
restored = true;
} catch (e) {}
});
}
return restored;
};
// 最后回退到滚动位置
if (!restored && typeof viewState.scrollPosition === 'number') {
const tryLogicalRange = function () {
if (!viewState.logicalRange || viewState.logicalRange.from === undefined || viewState.logicalRange.to === undefined) {
return false;
}
validCharts.forEach(c => {
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
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();
}
}
}
// 初始化图表