fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验
主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -981,19 +981,41 @@ function findBiCenters(biList) {
|
||||
var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) })
|
||||
gg = Math.max.apply(null, highs)
|
||||
dd = Math.min.apply(null, lows)
|
||||
endBiIdx = startIdx + addedAfterLeave.length
|
||||
endBiIdx = startIdx + 2 + addedAfterLeave.length
|
||||
}
|
||||
|
||||
var lastBiInCenter = biList_for_zs[biList_for_zs.length - 1]
|
||||
// 是否已离开中枢:之后出现完全在 ZG 之上或 ZD 之下的确认笔 → 中枢完成
|
||||
var zsSure = false
|
||||
var lastInListIdx = -1
|
||||
for (var li = 0; li < biList.length; li++) {
|
||||
if (biList[li] === lastBiInCenter || (biList[li].t0 === lastBiInCenter.t0 && biList[li].t1 === lastBiInCenter.t1)) {
|
||||
lastInListIdx = li
|
||||
break
|
||||
}
|
||||
}
|
||||
if (lastInListIdx < 0) lastInListIdx = endBiIdx
|
||||
for (var j = lastInListIdx + 1; j < biList.length; j++) {
|
||||
var leaveBi = biList[j]
|
||||
if (!leaveBi.sure) break
|
||||
var lbh = Math.max(leaveBi.p0, leaveBi.p1)
|
||||
var lbl = Math.min(leaveBi.p0, leaveBi.p1)
|
||||
if (lbl > zg || lbh < zd) {
|
||||
zsSure = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var zs = {
|
||||
t0: bi1.t0,
|
||||
t1: biList_for_zs[biList_for_zs.length - 1].t1,
|
||||
t1: lastBiInCenter.t1,
|
||||
high: zg, low: zd,
|
||||
zg: zg, zd: zd,
|
||||
gg: gg, dd: dd,
|
||||
is_sure: biList_for_zs[biList_for_zs.length - 1].sure,
|
||||
is_sure: zsSure,
|
||||
bi_count: biList_for_zs.length,
|
||||
bi_list: biList_for_zs, // 中枢内的笔列表(按序)
|
||||
start_bi_idx: startIdx, // 中枢首笔在总列表中的索引
|
||||
bi_list: biList_for_zs,
|
||||
start_bi_idx: startIdx,
|
||||
dir: zsDir,
|
||||
pre: lastZs,
|
||||
next: null,
|
||||
@@ -1008,28 +1030,6 @@ function findBiCenters(biList) {
|
||||
startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0)
|
||||
}
|
||||
|
||||
// 末中枢确认
|
||||
if (lastZs && !lastZs.is_sure) {
|
||||
var lastBiInZs = lastZs.bi_count > 0 ? biList_for_zs[biList_for_zs.length - 1] : null
|
||||
if (lastBiInZs) {
|
||||
var hasLeave = false
|
||||
var lastBiIdx = biList.indexOf(lastBiInZs)
|
||||
if (lastBiIdx >= 0) {
|
||||
for (var i = lastBiIdx + 1; i < biList.length; i++) {
|
||||
var bi = biList[i]
|
||||
if (bi.sure) {
|
||||
var bh = Math.max(bi.p0, bi.p1), bl = Math.min(bi.p0, bi.p1)
|
||||
var leave = (bl > lastZs.zg && bh > lastZs.zg) || (bh < lastZs.zd && bl < lastZs.zd)
|
||||
if (leave) { hasLeave = true; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasLeave && lastBiInZs.sure) {
|
||||
lastZs.t1 = lastBiInZs.t1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return zsList
|
||||
}
|
||||
|
||||
@@ -1119,16 +1119,37 @@ function findSegCenters(segs) {
|
||||
endSegIdx = startIdx + 2 + addedSegs.length
|
||||
}
|
||||
|
||||
var lastSegInCenter = segList_for_zs[segList_for_zs.length - 1]
|
||||
var zsSure = false
|
||||
var lastSegListIdx = -1
|
||||
for (var lsi = 0; lsi < segs.length; lsi++) {
|
||||
if (segs[lsi] === lastSegInCenter || (segs[lsi].t0 === lastSegInCenter.t0 && segs[lsi].t1 === lastSegInCenter.t1)) {
|
||||
lastSegListIdx = lsi
|
||||
break
|
||||
}
|
||||
}
|
||||
if (lastSegListIdx < 0) lastSegListIdx = endSegIdx
|
||||
for (var sj = lastSegListIdx + 1; sj < segs.length; sj++) {
|
||||
var leaveSeg = segs[sj]
|
||||
if (!leaveSeg.sure) break
|
||||
var lsh = Math.max(leaveSeg.p0, leaveSeg.p1)
|
||||
var lsl = Math.min(leaveSeg.p0, leaveSeg.p1)
|
||||
if (lsl > zg || lsh < zd) {
|
||||
zsSure = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var zs = {
|
||||
t0: s1.t0,
|
||||
t1: segs[endSegIdx].t1,
|
||||
t1: lastSegInCenter.t1,
|
||||
high: zg, low: zd,
|
||||
zg: zg, zd: zd,
|
||||
gg: gg, dd: dd,
|
||||
is_sure: segs[endSegIdx].sure,
|
||||
is_sure: zsSure,
|
||||
seg_count: segList_for_zs.length,
|
||||
seg_list: segList_for_zs, // 中枢内的段列表(按序)
|
||||
start_seg_idx: startIdx, // 中枢首段在总列表中的索引
|
||||
seg_list: segList_for_zs,
|
||||
start_seg_idx: startIdx,
|
||||
dir: zsDir,
|
||||
pre: lastZs,
|
||||
next: null,
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
}
|
||||
|
||||
// 中枢填充:区间内每根 bar 写入 top/bottom
|
||||
// 未完成中枢:右边界拉到最新 K(与主站 uncompleted_zs 一致)
|
||||
function fillZs(t0, t1, high, low, topField, botField) {
|
||||
var lo = lowerBound(sortedBarTimes, t0)
|
||||
var hi = upperBound(sortedBarTimes, t1)
|
||||
@@ -131,14 +132,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
var lastBarT = sortedBarTimes.length ? sortedBarTimes[sortedBarTimes.length - 1] : null
|
||||
|
||||
if (slice.zs) {
|
||||
slice.zs.forEach(function (z) {
|
||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
||||
var t1 = z.t1
|
||||
var sure = z.is_sure !== false && z.is_sure !== 0
|
||||
if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT)
|
||||
if (sure) {
|
||||
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
||||
} else {
|
||||
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_pending_top', 'zs_pending_bottom')
|
||||
}
|
||||
})
|
||||
}
|
||||
if (slice.segzs) {
|
||||
slice.segzs.forEach(function (z) {
|
||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
||||
var t1 = z.t1
|
||||
var sure = z.is_sure !== false && z.is_sure !== 0
|
||||
if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT)
|
||||
if (sure) {
|
||||
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
||||
} else {
|
||||
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_pending_top', 'segzs_pending_bottom')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,6 +250,10 @@
|
||||
{ id: 'zs_bottom', type: 'line' },
|
||||
{ id: 'segzs_top', type: 'line' },
|
||||
{ id: 'segzs_bottom', type: 'line' },
|
||||
{ id: 'zs_pending_top', type: 'line' },
|
||||
{ id: 'zs_pending_bottom', type: 'line' },
|
||||
{ id: 'segzs_pending_top', type: 'line' },
|
||||
{ id: 'segzs_pending_bottom', type: 'line' },
|
||||
]
|
||||
|
||||
BSP_SUBTYPES.forEach(function (t) {
|
||||
@@ -279,6 +300,22 @@
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
||||
}),
|
||||
zs_pending_top: mergeStyle('zs_pending_top', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#f1c40f', display: 0,
|
||||
}),
|
||||
zs_pending_bottom: mergeStyle('zs_pending_bottom', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#f1c40f', display: 0,
|
||||
}),
|
||||
segzs_pending_top: mergeStyle('segzs_pending_top', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#9b59b6', display: 0,
|
||||
}),
|
||||
segzs_pending_bottom: mergeStyle('segzs_pending_bottom', {
|
||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||
transparency: 100, visible: false, color: '#9b59b6', display: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
// BSP 样式
|
||||
@@ -311,6 +348,10 @@
|
||||
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
||||
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
||||
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
||||
zs_pending_top: { title: '未完成中枢上沿', histogramBase: 0, isHidden: true },
|
||||
zs_pending_bottom: { title: '未完成中枢下沿', histogramBase: 0, isHidden: true },
|
||||
segzs_pending_top: { title: '未完成段中枢上沿', histogramBase: 0, isHidden: true },
|
||||
segzs_pending_bottom: { title: '未完成段中枢下沿', histogramBase: 0, isHidden: true },
|
||||
}
|
||||
|
||||
BSP_SUBTYPES.forEach(function (t) {
|
||||
@@ -358,7 +399,7 @@
|
||||
name: '缠论',
|
||||
metainfo: {
|
||||
_metainfoVersion: 53,
|
||||
id: 'Chan@tv-basicstudies-5',
|
||||
id: 'Chan@tv-basicstudies-6',
|
||||
scriptIdPart: '',
|
||||
description: 'Chan 缠论',
|
||||
shortDescription: '缠论',
|
||||
@@ -373,12 +414,18 @@
|
||||
title: '中枢', isHidden: false },
|
||||
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
||||
title: '段中枢', isHidden: false },
|
||||
{ id: 'zs_pending_fill', objAId: 'zs_pending_top', objBId: 'zs_pending_bottom', type: 'plot_plot',
|
||||
title: '未完成中枢', isHidden: false },
|
||||
{ id: 'segzs_pending_fill', objAId: 'segzs_pending_top', objBId: 'segzs_pending_bottom', type: 'plot_plot',
|
||||
title: '未完成段中枢', isHidden: false },
|
||||
],
|
||||
defaults: {
|
||||
styles: styles,
|
||||
filledAreasStyle: {
|
||||
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
||||
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
||||
zs_pending_fill: mergeFill('zs_pending_fill', { color: '#f1c40f', visible: true, transparency: 55 }),
|
||||
segzs_pending_fill: mergeFill('segzs_pending_fill', { color: '#9b59b6', visible: true, transparency: 55 }),
|
||||
},
|
||||
precision: 2,
|
||||
inputs: { epoch: 0 },
|
||||
@@ -394,8 +441,8 @@
|
||||
self._context = ctx
|
||||
}
|
||||
this.main = function (context) {
|
||||
// 32 个 plot: 8 结构 + 24 BSP
|
||||
var NANS = new Array(32).fill(NaN)
|
||||
// 36 个 plot: 12 结构 + 24 BSP
|
||||
var NANS = new Array(36).fill(NaN)
|
||||
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
||||
var t = context.symbol.time
|
||||
if (isNaN(t)) return NANS
|
||||
@@ -415,6 +462,10 @@
|
||||
e.zs_bottom != null ? e.zs_bottom : NaN,
|
||||
e.segzs_top != null ? e.segzs_top : NaN,
|
||||
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
||||
e.zs_pending_top != null ? e.zs_pending_top : NaN,
|
||||
e.zs_pending_bottom != null ? e.zs_pending_bottom : NaN,
|
||||
e.segzs_pending_top != null ? e.segzs_pending_top : NaN,
|
||||
e.segzs_pending_bottom != null ? e.segzs_pending_bottom : NaN,
|
||||
]
|
||||
|
||||
BSP_SUBTYPES.forEach(function (sub) {
|
||||
|
||||
@@ -263,8 +263,9 @@ function updateTradingViewData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 重新显示笔、线段和中枢等图形
|
||||
redrawFractalElements();
|
||||
// 不再调用 redrawFractalElements():它会全量 initTradingView,
|
||||
// 与增量更新叠加会导致图表反复重建、内存暴涨。
|
||||
// 笔/段/中枢仍随「手动刷新 / 全量 refreshChart」重建;自动刷新走增量路径。
|
||||
|
||||
// 更新EMA52显示
|
||||
updateEMA52Display(currentData);
|
||||
|
||||
+54
-271
@@ -1,33 +1,56 @@
|
||||
/* chart_tv.js — split from chart.js */
|
||||
|
||||
/** 释放 Lightweight Charts 实例、DOM 与全局事件,避免自动刷新内存泄漏 */
|
||||
function disposeTradingViewCharts() {
|
||||
try {
|
||||
if (window._tvInitCleanups && Array.isArray(window._tvInitCleanups)) {
|
||||
window._tvInitCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||
}
|
||||
window._tvInitCleanups = [];
|
||||
if (window._bindSyncCleanups && Array.isArray(window._bindSyncCleanups)) {
|
||||
window._bindSyncCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||
}
|
||||
window._bindSyncCleanups = [];
|
||||
if (window._tooltipCleanups && Array.isArray(window._tooltipCleanups)) {
|
||||
window._tooltipCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||
}
|
||||
window._tooltipCleanups = [];
|
||||
|
||||
document.querySelectorAll(
|
||||
'.volume-crosshair-line, .atr-crosshair-line, .macd-crosshair-line, .chanmacd-crosshair-line'
|
||||
).forEach(function (el) { try { el.remove(); } catch (e) {} });
|
||||
|
||||
if (typeof clearEMA52Series === 'function') {
|
||||
try { clearEMA52Series(); } catch (e) {}
|
||||
}
|
||||
|
||||
if (tvWidget) {
|
||||
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) {
|
||||
try {
|
||||
if (tvWidget[key] && typeof tvWidget[key].remove === 'function') {
|
||||
tvWidget[key].remove();
|
||||
}
|
||||
} catch (e) {}
|
||||
tvWidget[key] = null;
|
||||
});
|
||||
if (tvWidget.state) {
|
||||
tvWidget.state.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
var chartRoot = document.getElementById('tradingview_chart');
|
||||
if (chartRoot) {
|
||||
chartRoot.innerHTML = '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('disposeTradingViewCharts 失败(可忽略):', e);
|
||||
}
|
||||
}
|
||||
|
||||
function initTradingView(symbol, timeframe) {
|
||||
try {
|
||||
// 在重新初始化前,尝试释放旧图表与系列资源,避免 GPU 内存累积
|
||||
try {
|
||||
if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) {
|
||||
// 主图
|
||||
if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') {
|
||||
tvWidget.mainChart.remove();
|
||||
}
|
||||
// 成交量
|
||||
if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') {
|
||||
tvWidget.volumeChart.remove();
|
||||
}
|
||||
// 旧 MACD(若存在)
|
||||
if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') {
|
||||
tvWidget.macdChart.remove();
|
||||
}
|
||||
// 新 ChanMACD(若存在)
|
||||
if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') {
|
||||
tvWidget.chanMacdChart.remove();
|
||||
}
|
||||
// ATR
|
||||
if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') {
|
||||
tvWidget.atrChart.remove();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('释放旧图表资源失败(可忽略):', e);
|
||||
}
|
||||
// 每次重建前完整释放,防止自动刷新导致 GPU/监听器泄漏
|
||||
disposeTradingViewCharts();
|
||||
console.log('初始化TradingView图表:', symbol, timeframe);
|
||||
|
||||
// 获取当前交易对的配置
|
||||
@@ -98,11 +121,7 @@ try {
|
||||
candles = filterTradingHours(candles, symbolConfig);
|
||||
console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`);
|
||||
}
|
||||
// 清除图表容器(释放旧 DOM 与 Canvas)
|
||||
const chartRoot = document.getElementById('tradingview_chart');
|
||||
if (chartRoot) chartRoot.innerHTML = '';
|
||||
|
||||
// 重置图表对象
|
||||
// 重置图表对象(容器已在 disposeTradingViewCharts 清空)
|
||||
tvWidget = {
|
||||
mainChart: null,
|
||||
volumeChart: null,
|
||||
@@ -235,8 +254,7 @@ try {
|
||||
container.appendChild(chanMacdChartContainer);
|
||||
}
|
||||
|
||||
// 防止同步过程中的无限循环
|
||||
let syncInProgress = false;
|
||||
// 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责)
|
||||
|
||||
// 创建统一的图表选项
|
||||
const createChartOptions = (showTimeScale = true, chartType = 'main') => {
|
||||
@@ -1052,243 +1070,8 @@ try {
|
||||
window.kluDivMarkersSubSub = [];
|
||||
}
|
||||
|
||||
// 实现三图联动滚动
|
||||
|
||||
// 同步图表的时间范围
|
||||
function syncCharts(sourceChart, sourceContainer) {
|
||||
// 防止无限循环 - 使用更精确的检查
|
||||
if (syncInProgress) {
|
||||
console.log('🔄 同步正在进行中,跳过此次同步');
|
||||
return;
|
||||
}
|
||||
|
||||
syncInProgress = true;
|
||||
console.log('🚀 开始同步图表,来源:',
|
||||
sourceChart === mainChart ? '主图' :
|
||||
sourceChart === volumeChart ? '成交量图' :
|
||||
sourceChart === atrChart ? 'ATR图' :
|
||||
sourceChart === macdChart ? 'MACD图' :
|
||||
sourceChart === chanMacdChart ? 'ChanMACD图' : '未知图表');
|
||||
|
||||
try {
|
||||
if (sourceChart && sourceChart.timeScale) {
|
||||
const logicalRange = sourceChart.timeScale().getVisibleLogicalRange();
|
||||
|
||||
if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) {
|
||||
console.log('📊 同步时间范围:', logicalRange);
|
||||
|
||||
// 同步主图
|
||||
if (sourceChart !== mainChart && mainChart && mainChart.timeScale) {
|
||||
try {
|
||||
mainChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
console.log('✅ 主图同步完成');
|
||||
} catch (e) {
|
||||
console.error('❌ 主图同步失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 同步成交量图
|
||||
if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) {
|
||||
try {
|
||||
volumeChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
console.log('✅ 成交量图同步完成');
|
||||
} catch (e) {
|
||||
console.error('❌ 成交量图同步失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 同步ATR图
|
||||
if (sourceChart !== atrChart && atrChart && atrChart.timeScale) {
|
||||
try {
|
||||
atrChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
console.log('✅ ATR图同步完成');
|
||||
} catch (e) {
|
||||
console.error('❌ ATR图同步失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 同步MACD图
|
||||
if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) {
|
||||
try {
|
||||
macdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
console.log('✅ MACD图同步完成');
|
||||
} catch (e) {
|
||||
console.error('❌ MACD图同步失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 同步ChanMACD图
|
||||
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
|
||||
try {
|
||||
chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
console.log('✅ ChanMACD图同步完成');
|
||||
} catch (e) {
|
||||
console.error('❌ ChanMACD图同步失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前的可见范围到全局状态
|
||||
if (tvWidget && tvWidget.state) {
|
||||
tvWidget.state.logicalRange = logicalRange;
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ 无效的逻辑范围:', logicalRange);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ 无效的源图表或时间刻度');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('💥 同步图表出错:', e);
|
||||
}
|
||||
|
||||
// 立即重置同步标志,提高响应速度
|
||||
setTimeout(() => {
|
||||
syncInProgress = false;
|
||||
console.log('🔓 同步标志已重置');
|
||||
}, 1);
|
||||
}
|
||||
|
||||
// 用于跟踪所有图表的拖动状态
|
||||
let localDragStates = {
|
||||
main: false,
|
||||
volume: false,
|
||||
atr: false,
|
||||
macd: false,
|
||||
chanmacd: false
|
||||
};
|
||||
|
||||
// 全局鼠标抬起事件(只添加一次)
|
||||
document.addEventListener('mouseup', () => {
|
||||
// 重置所有拖动状态
|
||||
Object.keys(localDragStates).forEach(key => {
|
||||
if (localDragStates[key]) {
|
||||
console.log(`全局鼠标抬起,重置${key}图表拖动状态`);
|
||||
localDragStates[key] = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 为每个图表添加事件监听
|
||||
const addChartSyncEvents = (chartContainer, chart) => {
|
||||
console.log('为图表添加同步事件监听:',
|
||||
chart === mainChart ? '主图' :
|
||||
chart === volumeChart ? '成交量图' :
|
||||
chart === atrChart ? 'ATR图' :
|
||||
chart === macdChart ? 'MACD图' :
|
||||
chart === chanMacdChart ? 'ChanMACD图' : '未知图表');
|
||||
|
||||
// 确定当前图表类型
|
||||
const chartType = chart === mainChart ? 'main' :
|
||||
chart === volumeChart ? 'volume' :
|
||||
chart === atrChart ? 'atr' :
|
||||
chart === macdChart ? 'macd' :
|
||||
chart === chanMacdChart ? 'chanmacd' : 'unknown';
|
||||
|
||||
// 使用LightweightCharts内置的时间范围变化事件(这是最可靠的方法)
|
||||
chart.timeScale().subscribeVisibleTimeRangeChange(() => {
|
||||
// 使用图表特定的同步标志防止递归
|
||||
if (!syncInProgress) {
|
||||
console.log('✅ 检测到时间范围变化,触发同步:', chartType, '当前范围:', chart.timeScale().getVisibleLogicalRange());
|
||||
syncCharts(chart, chartContainer);
|
||||
} else {
|
||||
console.log('⏸️ 同步进行中,跳过时间范围变化事件:', chartType);
|
||||
}
|
||||
});
|
||||
|
||||
// 备用的DOM事件监听(用于调试和额外保障)
|
||||
let isScrolling = false;
|
||||
|
||||
// 鼠标按下事件
|
||||
chartContainer.addEventListener('mousedown', (e) => {
|
||||
localDragStates[chartType] = true;
|
||||
console.log('鼠标按下开始拖动:', chartType);
|
||||
});
|
||||
|
||||
// 鼠标抬起事件
|
||||
chartContainer.addEventListener('mouseup', (e) => {
|
||||
if (localDragStates[chartType]) {
|
||||
localDragStates[chartType] = false;
|
||||
console.log('鼠标抬起,结束拖动:', chartType);
|
||||
}
|
||||
});
|
||||
|
||||
// 鼠标离开事件
|
||||
chartContainer.addEventListener('mouseleave', (e) => {
|
||||
if (localDragStates[chartType]) {
|
||||
localDragStates[chartType] = false;
|
||||
console.log('鼠标离开容器,结束拖动:', chartType);
|
||||
}
|
||||
});
|
||||
|
||||
// 滚轮缩放事件(保持原有逻辑)
|
||||
chartContainer.addEventListener('wheel', (e) => {
|
||||
if (!isScrolling) {
|
||||
isScrolling = true;
|
||||
console.log('滚轮缩放:', chartType);
|
||||
setTimeout(() => {
|
||||
if (!syncInProgress) {
|
||||
syncCharts(chart, chartContainer);
|
||||
}
|
||||
isScrolling = false;
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 添加事件监听
|
||||
addChartSyncEvents(mainChartContainer, mainChart);
|
||||
addChartSyncEvents(volumeChartContainer, volumeChart);
|
||||
addChartSyncEvents(atrChartContainer, atrChart);
|
||||
if (showMacd && macdChart) {
|
||||
addChartSyncEvents(macdChartContainer, macdChart);
|
||||
}
|
||||
if (showMacd && chanMacdChart) {
|
||||
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
|
||||
}
|
||||
|
||||
// 窗口大小变化时重绘图表
|
||||
window.addEventListener('resize', () => {
|
||||
// 调整主图大小
|
||||
mainChart.applyOptions({
|
||||
width: mainChartContainer.clientWidth,
|
||||
height: mainChartContainer.clientHeight
|
||||
});
|
||||
|
||||
// 调整成交量图大小
|
||||
volumeChart.applyOptions({
|
||||
width: volumeChartContainer.clientWidth,
|
||||
height: volumeChartContainer.clientHeight
|
||||
});
|
||||
|
||||
// 调整ATR图大小
|
||||
atrChart.applyOptions({
|
||||
width: atrChartContainer.clientWidth,
|
||||
height: atrChartContainer.clientHeight
|
||||
});
|
||||
|
||||
// 调整MACD图大小
|
||||
if (showMacd && macdChart && macdChartContainer) {
|
||||
macdChart.applyOptions({
|
||||
width: macdChartContainer.clientWidth,
|
||||
height: macdChartContainer.clientHeight
|
||||
});
|
||||
}
|
||||
|
||||
// 调整ChanMACD图大小
|
||||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
||||
chanMacdChart.applyOptions({
|
||||
width: chanMacdChartContainer.clientWidth,
|
||||
height: chanMacdChartContainer.clientHeight
|
||||
});
|
||||
}
|
||||
|
||||
// 重新同步 - 使用主图作为同步源
|
||||
setTimeout(() => {
|
||||
if (mainChart) {
|
||||
syncCharts(mainChart, mainChartContainer);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
// 图表同步事件统一由文末 bindSyncEvents 注册(带 cleanup),此处不再重复 addEventListener,
|
||||
// 否则每次自动刷新/重建都会在 document/window 上堆积监听导致内存泄漏。
|
||||
// 显示笔的绘制 - 分别处理主周期、次周期和次次周期
|
||||
if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) {
|
||||
console.log('绘制笔 - 已启用');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* chart_view.js — split from chart.js */
|
||||
function updateChart() {
|
||||
function updateChart(options) {
|
||||
options = options || {};
|
||||
// 只显示旋转加载图标
|
||||
$('#refreshLoadingSpinner').show();
|
||||
|
||||
@@ -18,7 +19,7 @@ function updateChart() {
|
||||
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
||||
|
||||
// 确保时区参数有效
|
||||
console.log('更新图表使用时区:', timezone);
|
||||
console.log('更新图表使用时区:', timezone, 'reason:', options.reason || (options.fromAutoRefresh ? 'auto' : 'manual'));
|
||||
console.log('数据源:', dataSource, '交易对/股票:', symbol);
|
||||
|
||||
// 如果symbol为空,不发送请求
|
||||
@@ -41,10 +42,15 @@ function updateChart() {
|
||||
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) {}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestId = ++lastRequestId; // 标记本次请求
|
||||
$.ajax({
|
||||
window._analyzeXhr = $.ajax({
|
||||
url: '/api/analyze',
|
||||
data: {
|
||||
symbol: symbol,
|
||||
@@ -75,15 +81,25 @@ function updateChart() {
|
||||
}
|
||||
currentData = data;
|
||||
|
||||
refreshChart(data);
|
||||
refreshChart(data, {
|
||||
incremental: options.incremental !== undefined
|
||||
? !!options.incremental
|
||||
: !!options.fromAutoRefresh
|
||||
});
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
// 隐藏加载图标
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
if (textStatus === 'abort') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示错误信息
|
||||
console.error('加载数据失败:', errorThrown);
|
||||
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
||||
// 自动刷新失败不弹窗打扰
|
||||
if (!options.fromAutoRefresh) {
|
||||
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
/**
|
||||
* TradingView Datafeed — 对接 Data Provider 微服务
|
||||
*
|
||||
* 数据源: http://103.179.242.166
|
||||
* - GET /timeframes → 可用周期
|
||||
* - GET /api/candles → 历史 OHLCV
|
||||
* - WS /ws → 实时 K 线推送
|
||||
* REST: https://provider.jackyu66.com
|
||||
* WS: wss://jackyu66.com/ws (可通过 window.DATA_SERVICE_* 或 URL 参数覆盖)
|
||||
*
|
||||
* 实现 IDatafeedChartApi 核心接口:
|
||||
* onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars
|
||||
* 实时:subscribeBars → 收到 kline 后调用 onTick(由 Charting Library 增量更新,不重置缩放)
|
||||
*/
|
||||
|
||||
var ChanTVDatafeed = (function () {
|
||||
'use strict'
|
||||
|
||||
// 默认 data_provider 地址,可通过 URL param 覆盖
|
||||
var DATA_HOST = 'http://103.179.242.166'
|
||||
function getParam(name) {
|
||||
try {
|
||||
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
|
||||
return m ? decodeURIComponent(m[1]) : ''
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// REST 与 WS 可分离(nginx 反代)
|
||||
var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host') || 'https://provider.jackyu66.com').replace(/\/$/, '')
|
||||
var WS_URL = (window.DATA_SERVICE_WS_URL || getParam('ws_url') || 'wss://jackyu66.com/ws').replace(/\/$/, '')
|
||||
|
||||
// ---- resolution <-> timeframe 转换 ----
|
||||
var RES_TO_TF = {
|
||||
@@ -36,13 +43,12 @@ var ChanTVDatafeed = (function () {
|
||||
var ws = null
|
||||
var wsReconnectTimer = null
|
||||
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
|
||||
var wsUrl = DATA_HOST.replace(/^http/, 'ws') + '/ws'
|
||||
|
||||
function wsConnect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
ws = new WebSocket(WS_URL)
|
||||
} catch (e) {
|
||||
console.warn('[TV Datafeed] WS 连接失败', e)
|
||||
scheduleReconnect()
|
||||
@@ -50,7 +56,7 @@ var ChanTVDatafeed = (function () {
|
||||
}
|
||||
|
||||
ws.onopen = function () {
|
||||
console.log('[TV Datafeed] WS 已连接')
|
||||
console.log('[TV Datafeed] WS 已连接', WS_URL)
|
||||
// 重新订阅
|
||||
Object.keys(wsSubs).forEach(function (guid) {
|
||||
var sub = wsSubs[guid]
|
||||
@@ -62,28 +68,35 @@ var ChanTVDatafeed = (function () {
|
||||
try {
|
||||
var msg = JSON.parse(evt.data)
|
||||
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
|
||||
// 历史快照交给 getBars;实时只走 kline → onTick,避免冲掉缩放
|
||||
if (msg.type === 'snapshot' || msg.type === 'subscribed') return
|
||||
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
|
||||
// 只推送最新一根 bar,避免历史快照造成时间顺序冲突
|
||||
// 按时间升序排列取最后一个
|
||||
var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) })
|
||||
var latest = sorted[sorted.length - 1]
|
||||
// 广播给所有匹配的 subscriber
|
||||
if (!latest || latest.timestamp == null) return
|
||||
Object.keys(wsSubs).forEach(function (guid) {
|
||||
var sub = wsSubs[guid]
|
||||
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
|
||||
// 跳过已处理过的时间戳
|
||||
if (sub.lastTickTime && latest.timestamp <= sub.lastTickTime) return
|
||||
// 允许同 timestamp 更新未收盘棒(用 < 而不是 <=)
|
||||
if (sub.lastTickTime != null && latest.timestamp < sub.lastTickTime) return
|
||||
var tick = {
|
||||
time: latest.timestamp,
|
||||
open: latest.open,
|
||||
high: latest.high,
|
||||
low: latest.low,
|
||||
close: latest.close,
|
||||
volume: latest.volume,
|
||||
}
|
||||
try {
|
||||
sub.onTick({
|
||||
time: latest.timestamp,
|
||||
open: latest.open,
|
||||
high: latest.high,
|
||||
low: latest.low,
|
||||
close: latest.close,
|
||||
volume: latest.volume,
|
||||
})
|
||||
sub.onTick(tick)
|
||||
sub.lastTickTime = latest.timestamp
|
||||
} catch (e) { /* ignore */ }
|
||||
// 通知页面:更新缠论缓存(K 线由 TV onTick 处理,不重置缩放)
|
||||
try {
|
||||
if (window.ChanTvRealtime && typeof window.ChanTvRealtime.onBar === 'function') {
|
||||
window.ChanTvRealtime.onBar(msg.symbol, msg.timeframe, latest)
|
||||
}
|
||||
} catch (e2) { /* ignore */ }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+37
-7
@@ -88,15 +88,13 @@ $(document).ready(function() {
|
||||
.always(function() {
|
||||
loadAStockSymbols();
|
||||
startAStockStatusUpdater();
|
||||
// A 股:metadata 完成后再拉数(下方不再重复 updateChart)
|
||||
setTimeout(function() {
|
||||
updateChart();
|
||||
}, 300);
|
||||
});
|
||||
} else {
|
||||
setTimeout(function() {
|
||||
updateChart();
|
||||
}, 500);
|
||||
}
|
||||
// 加密货币:统一在文末单次 updateChart,避免重复请求
|
||||
|
||||
// 初始化交易对下拉菜单
|
||||
$('#symbol').val('BTC/USDT:USDT');
|
||||
@@ -245,6 +243,7 @@ $(document).ready(function() {
|
||||
// 自动刷新相关变量
|
||||
let autoRefreshTimer = null;
|
||||
let nextRefreshTime = null;
|
||||
let autoRefreshTick = 0;
|
||||
// 初始化自动刷新功能
|
||||
function initAutoRefresh() {
|
||||
// 监听自动刷新勾选框变化
|
||||
@@ -281,12 +280,18 @@ function startAutoRefresh() {
|
||||
updateNextRefreshTimeDisplay();
|
||||
|
||||
// 启动定时器
|
||||
autoRefreshTick = 0;
|
||||
autoRefreshTimer = setInterval(function() {
|
||||
// 更新结束时间为当前时间
|
||||
updateEndTimeToNow();
|
||||
|
||||
// 刷新图表
|
||||
updateChart();
|
||||
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
|
||||
autoRefreshTick += 1;
|
||||
const fullRebuild = (autoRefreshTick % 6) === 0;
|
||||
updateChart({
|
||||
fromAutoRefresh: true,
|
||||
incremental: !fullRebuild
|
||||
});
|
||||
|
||||
// 更新下次刷新时间
|
||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||
@@ -512,7 +517,7 @@ function updateFractalTables() {
|
||||
}
|
||||
|
||||
// 刷新图表并更新表格
|
||||
function refreshChart(data) {
|
||||
function refreshChart(data, options) {
|
||||
// 检查是否接收到数据
|
||||
if (!data) {
|
||||
console.error('未收到数据,无法刷新图表');
|
||||
@@ -522,6 +527,31 @@ function refreshChart(data) {
|
||||
if (data.element_timeframe) {
|
||||
$('#elementTimeframe').val(data.element_timeframe);
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
const preferIncremental = !!options.incremental;
|
||||
const chartsReady = tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart;
|
||||
|
||||
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
|
||||
if (preferIncremental && chartsReady) {
|
||||
try {
|
||||
if (tvWidget.mainChart) {
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
} catch (e) {
|
||||
window._pendingRestoreView = null;
|
||||
}
|
||||
}
|
||||
updateTradingViewData();
|
||||
updateTables(data);
|
||||
if (currentData && currentData.ema52_dict) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('增量刷新失败,回退全量重建:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
||||
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
||||
|
||||
Reference in New Issue
Block a user