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:
+157
-33
@@ -1,5 +1,7 @@
|
||||
/* chart_sync.js — split from chart.js */
|
||||
function updateTradingViewData() {
|
||||
function updateTradingViewData(options) {
|
||||
options = options || {};
|
||||
const tailOnly = !!options.tailOnly;
|
||||
try {
|
||||
console.log('增量更新图表数据');
|
||||
|
||||
@@ -9,11 +11,34 @@ function updateTradingViewData() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前的可视范围
|
||||
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳)
|
||||
const frozen = window._preserveViewOnRefresh;
|
||||
const oldBarCount = window._preserveViewBarCount || 0;
|
||||
let savedScrollPosition = null;
|
||||
let savedVisibleRange = null;
|
||||
let savedLogicalRange = null;
|
||||
if (tvWidget.mainChart) {
|
||||
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
|
||||
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
||||
const ts = tvWidget.mainChart.timeScale();
|
||||
if (frozen) {
|
||||
savedVisibleRange = frozen.visibleRange;
|
||||
savedLogicalRange = frozen.logicalRange;
|
||||
savedScrollPosition = (typeof frozen.scrollPosition === 'number') ? frozen.scrollPosition : null;
|
||||
} else {
|
||||
try { savedVisibleRange = ts.getVisibleRange(); } catch (e) {}
|
||||
try { savedLogicalRange = ts.getVisibleLogicalRange(); } catch (e) {}
|
||||
try {
|
||||
savedScrollPosition = ts.scrollPosition ? ts.scrollPosition() : null;
|
||||
} catch (e) {}
|
||||
}
|
||||
if (tvWidget.state) {
|
||||
tvWidget.state.visibleRange = savedVisibleRange;
|
||||
tvWidget.state.logicalRange = savedLogicalRange;
|
||||
}
|
||||
}
|
||||
window._preserveViewOnRefresh = null;
|
||||
window._preserveViewBarCount = 0;
|
||||
// setData 会触发 timeRange 回调;期间禁止 sync 写回 state(否则会把已跳回左侧的视窗当成「要恢复的目标」)
|
||||
window._preserveViewDuringUpdate = true;
|
||||
|
||||
// 检查是否显示原始K线
|
||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||
@@ -71,11 +96,32 @@ function updateTradingViewData() {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// LWC 不允许 null/NaN;时间用整秒,避免 Line 渲染抛 Value is null
|
||||
candles = (candles || []).filter(function (c) {
|
||||
return c && c.time != null &&
|
||||
isFinite(Number(c.open)) && isFinite(Number(c.high)) &&
|
||||
isFinite(Number(c.low)) && isFinite(Number(c.close));
|
||||
}).map(function (c) {
|
||||
return {
|
||||
time: Math.floor(Number(c.time)),
|
||||
open: Number(c.open),
|
||||
high: Number(c.high),
|
||||
low: Number(c.low),
|
||||
close: Number(c.close)
|
||||
};
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// 更新主系列数据(根据klineType)
|
||||
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||||
if (klineType === 'candlestick' && tvWidget.series.candleSeries) {
|
||||
tvWidget.series.candleSeries.setData(candles);
|
||||
applySeriesDataTail(tvWidget.series.candleSeries, candles, tailOnly);
|
||||
} else if (klineType === 'renko' && tvWidget.series.renkoSeries) {
|
||||
const bricks = buildRenkoFromCandles(candles);
|
||||
tvWidget.series.renkoSeries.setData(bricks);
|
||||
@@ -83,26 +129,30 @@ function updateTradingViewData() {
|
||||
const hk = buildHeikinFromCandles(candles);
|
||||
tvWidget.series.heikinSeries.setData(hk);
|
||||
} else if (klineType === 'bar' && tvWidget.series.barSeries) {
|
||||
tvWidget.series.barSeries.setData(candles);
|
||||
applySeriesDataTail(tvWidget.series.barSeries, candles, tailOnly);
|
||||
} else if (klineType === 'line' && tvWidget.series.lineSeries) {
|
||||
const lineData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.lineSeries.setData(lineData);
|
||||
applySeriesDataTail(tvWidget.series.lineSeries, lineData, tailOnly);
|
||||
} else if (klineType === 'area' && tvWidget.series.areaSeries) {
|
||||
const areaData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.areaSeries.setData(areaData);
|
||||
applySeriesDataTail(tvWidget.series.areaSeries, areaData, tailOnly);
|
||||
} else if (klineType === 'baseline' && tvWidget.series.baselineSeries) {
|
||||
const baseData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.baselineSeries.setData(baseData);
|
||||
applySeriesDataTail(tvWidget.series.baselineSeries, baseData, tailOnly);
|
||||
} else if (klineType === 'klc' && tvWidget.series.klcSeries) {
|
||||
const klcCandles = buildKLCFromAnalysis(currentData);
|
||||
tvWidget.series.klcSeries.setData(klcCandles);
|
||||
if (tailOnly) {
|
||||
applySeriesDataTail(tvWidget.series.klcSeries, klcCandles, true);
|
||||
} else {
|
||||
tvWidget.series.klcSeries.setData(klcCandles);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新均线数据
|
||||
addMovingAveragesToChart(candles);
|
||||
|
||||
// 更新布林带数据
|
||||
addBollingerBandsToChart(candles);
|
||||
// 尾部刷新不重算均线/布林带(removeSeries 会触发视窗跳动)
|
||||
if (!tailOnly) {
|
||||
addMovingAveragesToChart(candles);
|
||||
addBollingerBandsToChart(candles);
|
||||
}
|
||||
|
||||
// 更新成交量数据
|
||||
let volumes = [];
|
||||
@@ -136,9 +186,11 @@ function updateTradingViewData() {
|
||||
}
|
||||
|
||||
if (tvWidget.series.volumeSeries) {
|
||||
tvWidget.series.volumeSeries.setData(volumes);
|
||||
applySeriesDataTail(tvWidget.series.volumeSeries, volumes, tailOnly);
|
||||
}
|
||||
|
||||
// 尾部刷新不重拉 ATR/MACD(setData 会触发视窗跳到最右)
|
||||
if (!tailOnly) {
|
||||
// 更新ATR数据
|
||||
if (tvWidget.series.atrLineSeries) {
|
||||
const atrData = [];
|
||||
@@ -262,6 +314,7 @@ function updateTradingViewData() {
|
||||
console.warn('更新ChanMACD标注失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不再调用 redrawFractalElements():它会全量 initTradingView,
|
||||
// 与增量更新叠加会导致图表反复重建、内存暴涨。
|
||||
@@ -270,27 +323,98 @@ function updateTradingViewData() {
|
||||
// 更新EMA52显示
|
||||
updateEMA52Display(currentData);
|
||||
|
||||
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
|
||||
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。
|
||||
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
|
||||
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
|
||||
if (tvWidget.mainChart) {
|
||||
if (tvWidget.state.visibleRange) {
|
||||
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
|
||||
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
} else if (tvWidget.state.logicalRange) {
|
||||
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
|
||||
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
}
|
||||
const charts = [
|
||||
tvWidget.mainChart,
|
||||
tvWidget.volumeChart,
|
||||
tvWidget.atrChart,
|
||||
tvWidget.macdChart,
|
||||
tvWidget.chanMacdChart
|
||||
].filter(Boolean);
|
||||
|
||||
const vr = clampedVisibleRange || savedVisibleRange;
|
||||
const lr = savedLogicalRange;
|
||||
const savedScroll = savedScrollPosition;
|
||||
const mainChartRef = tvWidget.mainChart;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const finishPreserve = function () {
|
||||
window._preserveViewDuringUpdate = false;
|
||||
if (tvWidget.mainChart && tvWidget.state) {
|
||||
try {
|
||||
const ts = tvWidget.mainChart.timeScale();
|
||||
tvWidget.state.logicalRange = ts.getVisibleLogicalRange();
|
||||
tvWidget.state.visibleRange = ts.getVisibleRange();
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
applyPosition('');
|
||||
setTimeout(function () { applyPosition('@0'); }, 0);
|
||||
setTimeout(function () { applyPosition('@50'); }, 50);
|
||||
setTimeout(function () {
|
||||
applyPosition('@150');
|
||||
finishPreserve();
|
||||
}, 150);
|
||||
} else {
|
||||
window._preserveViewDuringUpdate = false;
|
||||
}
|
||||
|
||||
console.log('增量更新图表完成');
|
||||
} catch (e) {
|
||||
window._preserveViewDuringUpdate = false;
|
||||
console.error('增量更新图表错误,回退到完全重绘:', e);
|
||||
// 出错时回退到完全重绘
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
@@ -316,7 +440,7 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
|
||||
// 同步图表的时间范围
|
||||
function syncCharts(sourceChart, sourceContainer) {
|
||||
if (syncInProgress) return;
|
||||
if (syncInProgress || window._preserveViewDuringUpdate) return;
|
||||
|
||||
syncInProgress = true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user