主周期笔/KLC 分型标记在切到 1m/2m 主图时未对齐 K 线 time;过滤均线无效点并钳制视窗恢复。顺带统一 BI 中枢计算路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
792 lines
42 KiB
JavaScript
792 lines
42 KiB
JavaScript
/* chart_sync.js — split from chart.js */
|
||
function updateTradingViewData() {
|
||
try {
|
||
console.log('增量更新图表数据');
|
||
|
||
// 检查 currentData 是否存在
|
||
if (!currentData) {
|
||
console.error('currentData为空,无法更新图表');
|
||
return;
|
||
}
|
||
|
||
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳)
|
||
const frozen = window._preserveViewOnRefresh;
|
||
const oldBarCount = window._preserveViewBarCount || 0;
|
||
let savedScrollPosition = null;
|
||
if (tvWidget.mainChart) {
|
||
const ts = tvWidget.mainChart.timeScale();
|
||
if (frozen) {
|
||
tvWidget.state.visibleRange = frozen.visibleRange;
|
||
tvWidget.state.logicalRange = frozen.logicalRange;
|
||
savedScrollPosition = (typeof frozen.scrollPosition === 'number') ? frozen.scrollPosition : null;
|
||
} else {
|
||
tvWidget.state.visibleRange = ts.getVisibleRange();
|
||
tvWidget.state.logicalRange = ts.getVisibleLogicalRange();
|
||
try {
|
||
savedScrollPosition = ts.scrollPosition ? ts.scrollPosition() : null;
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
window._preserveViewOnRefresh = null;
|
||
window._preserveViewBarCount = 0;
|
||
|
||
// 检查是否显示原始K线
|
||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||
|
||
// 检查是否使用次次周期 / 小周期数据
|
||
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') &&
|
||
currentData.sub_sub_timeframe &&
|
||
currentData.sub_sub_kline_data &&
|
||
Array.isArray(currentData.sub_sub_kline_data);
|
||
const useElementPeriod = !useSubSubPeriod &&
|
||
$('#elementPeriodKline').is(':checked') &&
|
||
currentData.element_timeframe &&
|
||
currentData.element_kline_data &&
|
||
Array.isArray(currentData.element_kline_data);
|
||
|
||
// 转换K线数据
|
||
let candles = [];
|
||
if (useSubSubPeriod) {
|
||
console.log('使用次次周期K线数据');
|
||
candles = currentData.sub_sub_kline_data.map((kline) => {
|
||
const date = new Date(kline.date);
|
||
const timestamp = date.getTime() / 1000;
|
||
return {
|
||
time: timestamp,
|
||
open: parseFloat(kline.open),
|
||
high: parseFloat(kline.high),
|
||
low: parseFloat(kline.low),
|
||
close: parseFloat(kline.close),
|
||
};
|
||
});
|
||
} else if (useElementPeriod) {
|
||
console.log('使用小周期K线数据');
|
||
candles = currentData.element_kline_data.map((kline) => {
|
||
const date = new Date(kline.date);
|
||
const timestamp = date.getTime() / 1000;
|
||
return {
|
||
time: timestamp,
|
||
open: parseFloat(kline.open),
|
||
high: parseFloat(kline.high),
|
||
low: parseFloat(kline.low),
|
||
close: parseFloat(kline.close),
|
||
};
|
||
});
|
||
} else if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||
console.log('使用主周期K线数据');
|
||
candles = currentData.kline_data.map((kline) => {
|
||
const date = new Date(kline.date);
|
||
const timestamp = date.getTime() / 1000;
|
||
return {
|
||
time: timestamp,
|
||
open: parseFloat(kline.open),
|
||
high: parseFloat(kline.high),
|
||
low: parseFloat(kline.low),
|
||
close: parseFloat(kline.close),
|
||
};
|
||
});
|
||
}
|
||
|
||
// 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;
|
||
|
||
// 更新主系列数据(根据klineType)
|
||
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||
if (klineType === 'candlestick' && tvWidget.series.candleSeries) {
|
||
tvWidget.series.candleSeries.setData(candles);
|
||
} else if (klineType === 'renko' && tvWidget.series.renkoSeries) {
|
||
const bricks = buildRenkoFromCandles(candles);
|
||
tvWidget.series.renkoSeries.setData(bricks);
|
||
} else if (klineType === 'heikin' && tvWidget.series.heikinSeries) {
|
||
const hk = buildHeikinFromCandles(candles);
|
||
tvWidget.series.heikinSeries.setData(hk);
|
||
} else if (klineType === 'bar' && tvWidget.series.barSeries) {
|
||
tvWidget.series.barSeries.setData(candles);
|
||
} else if (klineType === 'line' && tvWidget.series.lineSeries) {
|
||
const lineData = candles.map(c => ({ time: c.time, value: c.close }));
|
||
tvWidget.series.lineSeries.setData(lineData);
|
||
} else if (klineType === 'area' && tvWidget.series.areaSeries) {
|
||
const areaData = candles.map(c => ({ time: c.time, value: c.close }));
|
||
tvWidget.series.areaSeries.setData(areaData);
|
||
} else if (klineType === 'baseline' && tvWidget.series.baselineSeries) {
|
||
const baseData = candles.map(c => ({ time: c.time, value: c.close }));
|
||
tvWidget.series.baselineSeries.setData(baseData);
|
||
} else if (klineType === 'klc' && tvWidget.series.klcSeries) {
|
||
const klcCandles = buildKLCFromAnalysis(currentData);
|
||
tvWidget.series.klcSeries.setData(klcCandles);
|
||
}
|
||
|
||
// 更新均线数据
|
||
addMovingAveragesToChart(candles);
|
||
|
||
// 更新布林带数据
|
||
addBollingerBandsToChart(candles);
|
||
|
||
// 更新成交量数据
|
||
let volumes = [];
|
||
if (useSubSubPeriod && currentData.sub_sub_kline_data && Array.isArray(currentData.sub_sub_kline_data)) {
|
||
volumes = currentData.sub_sub_kline_data.map(kline => {
|
||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||
return {
|
||
time: timestamp,
|
||
value: parseFloat(kline.volume),
|
||
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
|
||
};
|
||
});
|
||
} else if (useElementPeriod && currentData.element_kline_data && Array.isArray(currentData.element_kline_data)) {
|
||
volumes = currentData.element_kline_data.map(kline => {
|
||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||
return {
|
||
time: timestamp,
|
||
value: parseFloat(kline.volume),
|
||
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
|
||
};
|
||
});
|
||
} else if (currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||
volumes = currentData.kline_data.map(kline => {
|
||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||
return {
|
||
time: timestamp,
|
||
value: parseFloat(kline.volume),
|
||
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
|
||
};
|
||
});
|
||
}
|
||
|
||
if (tvWidget.series.volumeSeries) {
|
||
tvWidget.series.volumeSeries.setData(volumes);
|
||
}
|
||
|
||
// 更新ATR数据
|
||
if (tvWidget.series.atrLineSeries) {
|
||
const atrData = [];
|
||
const atrDataSource = useSubSubPeriod ?
|
||
(currentData.sub_sub_atr || currentData.atr) :
|
||
(useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr);
|
||
|
||
if (atrDataSource && Array.isArray(atrDataSource)) {
|
||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||
// 修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据
|
||
for (let i = 0; i < klineDataSource.length; i++) {
|
||
const kline = klineDataSource[i];
|
||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||
|
||
// 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示
|
||
if (atrDataSource[i] !== undefined) {
|
||
if (atrDataSource[i] > 0) {
|
||
// ATR有效值,正常显示
|
||
atrData.push({
|
||
time: timestamp,
|
||
value: atrDataSource[i]
|
||
});
|
||
} else {
|
||
// ATR为0,添加时间点但不显示线条(使用undefined作为value)
|
||
atrData.push({
|
||
time: timestamp,
|
||
value: undefined
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('🔄 增量更新ATR数据点数:', atrData.length);
|
||
}
|
||
|
||
tvWidget.series.atrLineSeries.setData(atrData);
|
||
}
|
||
|
||
// 更新MACD数据
|
||
if (tvWidget.series.macdLineSeries && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||
// 提取MACD数据
|
||
const macdData = [];
|
||
const signalData = [];
|
||
const histogramData = [];
|
||
|
||
for (let i = 0; i < currentData.kline_data.length; i++) {
|
||
const kline = currentData.kline_data[i];
|
||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||
|
||
if (currentData.macd && currentData.macd.macd && currentData.macd.macd[i] !== undefined) {
|
||
macdData.push({
|
||
time: timestamp,
|
||
value: currentData.macd.macd[i]
|
||
});
|
||
|
||
signalData.push({
|
||
time: timestamp,
|
||
value: currentData.macd.signal[i]
|
||
});
|
||
|
||
// 设置直方图颜色
|
||
const histValue = currentData.macd.histogram[i];
|
||
histogramData.push({
|
||
time: timestamp,
|
||
value: histValue,
|
||
color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
|
||
});
|
||
}
|
||
}
|
||
|
||
tvWidget.series.macdLineSeries.setData(macdData);
|
||
tvWidget.series.signalLineSeries.setData(signalData);
|
||
tvWidget.series.histogramSeries.setData(histogramData);
|
||
}
|
||
|
||
// 更新 ChanMACD 数据与自定义标注
|
||
if (tvWidget.series.chanMacdLineSeries && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) {
|
||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||
const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
|
||
if (macdDataSource && macdDataSource.macd && macdDataSource.signal && macdDataSource.histogram) {
|
||
const chanMacdData = [];
|
||
const chanSignalData = [];
|
||
const chanHistData = [];
|
||
for (let i = 0; i < klineDataSource.length; i++) {
|
||
const kline = klineDataSource[i];
|
||
if (kline && kline.date && i < macdDataSource.macd.length && macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) {
|
||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||
chanMacdData.push({ time: timestamp, value: macdDataSource.macd[i] });
|
||
chanSignalData.push({ time: timestamp, value: macdDataSource.signal[i] });
|
||
chanHistData.push({ time: timestamp, value: macdDataSource.histogram[i], color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' });
|
||
}
|
||
}
|
||
if (chanMacdData.length > 0) {
|
||
tvWidget.series.chanMacdLineSeries.setData(chanMacdData);
|
||
tvWidget.series.chanMacdSignalSeries.setData(chanSignalData);
|
||
tvWidget.series.chanMacdHistSeries.setData(chanHistData);
|
||
}
|
||
}
|
||
// 重新应用自定义标注(段/UnitTF/HistSet/状态点)
|
||
try {
|
||
if (typeof clearChanMacdMarkers === 'function') clearChanMacdMarkers();
|
||
const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd);
|
||
const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain);
|
||
if (cm && allowU) {
|
||
addAllChanMacdMarkers(
|
||
cm.seg_list || [],
|
||
cm.unittf_list || [],
|
||
cm.histset_list || [],
|
||
{
|
||
high_position_list: cm.high_position_list || [],
|
||
high_empty_list: cm.high_empty_list || [],
|
||
low_position_list: cm.low_position_list || [],
|
||
low_empty_list: cm.low_empty_list || [],
|
||
return_zero_list: cm.return_zero_list || [],
|
||
cross0_up_list: cm.cross0_up_list || [],
|
||
cross0_down_list: cm.cross0_down_list || []
|
||
}
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.warn('更新ChanMACD标注失败:', e);
|
||
}
|
||
}
|
||
|
||
// 不再调用 redrawFractalElements():它会全量 initTradingView,
|
||
// 与增量更新叠加会导致图表反复重建、内存暴涨。
|
||
// 笔/段/中枢仍随「手动刷新 / 全量 refreshChart」重建;自动刷新走增量路径。
|
||
|
||
// 更新EMA52显示
|
||
updateEMA52Display(currentData);
|
||
|
||
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。
|
||
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
|
||
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
|
||
if (tvWidget.mainChart) {
|
||
const charts = [
|
||
tvWidget.mainChart,
|
||
tvWidget.volumeChart,
|
||
tvWidget.atrChart,
|
||
tvWidget.macdChart,
|
||
tvWidget.chanMacdChart
|
||
].filter(Boolean);
|
||
|
||
const vr = tvWidget.state.visibleRange;
|
||
const lr = tvWidget.state.logicalRange;
|
||
const savedScroll = savedScrollPosition;
|
||
|
||
const applyPosition = function (tag) {
|
||
let ok = false;
|
||
if (lr && lr.from !== undefined && lr.to !== undefined && newBarCount > 0) {
|
||
// 视窗超出当前 K 线数量时,LWC Line 绘制会抛 Value is null
|
||
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 (!ok && typeof savedScroll === 'number') {
|
||
const pos = savedScroll + (barDelta || 0);
|
||
charts.forEach(c => {
|
||
try { c.timeScale().scrollToPosition(pos, false); } catch (e) {}
|
||
});
|
||
console.log('🔄 恢复位置 scroll' + (tag || '') + ':', pos);
|
||
}
|
||
};
|
||
|
||
applyPosition('');
|
||
setTimeout(function () { applyPosition('@0'); }, 0);
|
||
setTimeout(function () { applyPosition('@50'); }, 50);
|
||
}
|
||
|
||
console.log('增量更新图表完成');
|
||
} catch (e) {
|
||
console.error('增量更新图表错误,回退到完全重绘:', e);
|
||
// 出错时回退到完全重绘
|
||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||
}
|
||
}
|
||
function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) {
|
||
// 清理上一轮绑定的事件监听器,防止累积
|
||
if (window._bindSyncCleanups) {
|
||
window._bindSyncCleanups.forEach(fn => { try { fn(); } catch(e) {} });
|
||
}
|
||
window._bindSyncCleanups = [];
|
||
|
||
let syncInProgress = false;
|
||
|
||
// 用于跟踪所有图表的拖动状态 - 在函数内部定义以确保作用域正确
|
||
let localDragStates = {
|
||
main: false,
|
||
volume: false,
|
||
atr: false,
|
||
macd: false,
|
||
chanmacd: false
|
||
};
|
||
|
||
// 同步图表的时间范围
|
||
function syncCharts(sourceChart, sourceContainer) {
|
||
if (syncInProgress) return;
|
||
|
||
syncInProgress = true;
|
||
|
||
try {
|
||
if (sourceChart && sourceChart.timeScale) {
|
||
const logicalRange = sourceChart.timeScale().getVisibleLogicalRange();
|
||
|
||
if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) {
|
||
if (sourceChart !== mainChart && mainChart && mainChart.timeScale) {
|
||
try { mainChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||
}
|
||
if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) {
|
||
try { volumeChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||
}
|
||
if (sourceChart !== atrChart && atrChart && atrChart.timeScale) {
|
||
try { atrChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||
}
|
||
if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) {
|
||
try { macdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||
}
|
||
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
|
||
try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||
}
|
||
|
||
if (tvWidget && tvWidget.state) {
|
||
tvWidget.state.logicalRange = logicalRange;
|
||
try { tvWidget.state.visibleRange = sourceChart.timeScale().getVisibleRange(); } catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('同步图表出错:', e);
|
||
}
|
||
|
||
setTimeout(() => { syncInProgress = false; }, 1);
|
||
}
|
||
|
||
// 为每个图表添加事件监听
|
||
const addChartSyncEvents = (chartContainer, chart) => {
|
||
const chartType = chart === mainChart ? 'main' :
|
||
chart === volumeChart ? 'volume' :
|
||
chart === atrChart ? 'atr' :
|
||
chart === macdChart ? 'macd' :
|
||
chart === chanMacdChart ? 'chanmacd' : 'unknown';
|
||
|
||
const timeRangeHandler = () => {
|
||
if (!syncInProgress) {
|
||
syncCharts(chart, chartContainer);
|
||
}
|
||
};
|
||
chart.timeScale().subscribeVisibleTimeRangeChange(timeRangeHandler);
|
||
window._bindSyncCleanups.push(() => {
|
||
try { chart.timeScale().unsubscribeVisibleTimeRangeChange(timeRangeHandler); } catch(e) {}
|
||
});
|
||
|
||
let isScrolling = false;
|
||
|
||
const mousedownHandler = () => { localDragStates[chartType] = true; };
|
||
const mouseupHandler = () => { localDragStates[chartType] = false; };
|
||
const mouseleaveHandler = () => { localDragStates[chartType] = false; };
|
||
const wheelHandler = () => {
|
||
if (!isScrolling) {
|
||
isScrolling = true;
|
||
setTimeout(() => {
|
||
if (!syncInProgress) {
|
||
syncCharts(chart, chartContainer);
|
||
}
|
||
isScrolling = false;
|
||
}, 50);
|
||
}
|
||
};
|
||
|
||
chartContainer.addEventListener('mousedown', mousedownHandler);
|
||
chartContainer.addEventListener('mouseup', mouseupHandler);
|
||
chartContainer.addEventListener('mouseleave', mouseleaveHandler);
|
||
chartContainer.addEventListener('wheel', wheelHandler);
|
||
window._bindSyncCleanups.push(() => {
|
||
chartContainer.removeEventListener('mousedown', mousedownHandler);
|
||
chartContainer.removeEventListener('mouseup', mouseupHandler);
|
||
chartContainer.removeEventListener('mouseleave', mouseleaveHandler);
|
||
chartContainer.removeEventListener('wheel', wheelHandler);
|
||
});
|
||
};
|
||
|
||
// 添加事件监听
|
||
if (mainChartContainer && mainChart) {
|
||
addChartSyncEvents(mainChartContainer, mainChart);
|
||
}
|
||
if (volumeChartContainer && volumeChart) {
|
||
addChartSyncEvents(volumeChartContainer, volumeChart);
|
||
}
|
||
if (atrChartContainer && atrChart) {
|
||
addChartSyncEvents(atrChartContainer, atrChart);
|
||
}
|
||
if (showMacd && macdChartContainer && macdChart) {
|
||
addChartSyncEvents(macdChartContainer, macdChart);
|
||
}
|
||
if (showMacd && chanMacdChartContainer && chanMacdChart) {
|
||
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
|
||
}
|
||
|
||
// 窗口大小变化时重绘图表 — 使用可清理的方式注册
|
||
const resizeHandler = () => {
|
||
if (mainChart && mainChartContainer) {
|
||
mainChart.applyOptions({ width: mainChartContainer.clientWidth, height: mainChartContainer.clientHeight });
|
||
}
|
||
if (volumeChart && volumeChartContainer) {
|
||
volumeChart.applyOptions({ width: volumeChartContainer.clientWidth, height: volumeChartContainer.clientHeight });
|
||
}
|
||
if (atrChart && atrChartContainer) {
|
||
atrChart.applyOptions({ width: atrChartContainer.clientWidth, height: atrChartContainer.clientHeight });
|
||
}
|
||
if (showMacd && macdChart && macdChartContainer) {
|
||
macdChart.applyOptions({ width: macdChartContainer.clientWidth, height: macdChartContainer.clientHeight });
|
||
}
|
||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
||
chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight });
|
||
}
|
||
setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200);
|
||
};
|
||
window.addEventListener('resize', resizeHandler);
|
||
window._bindSyncCleanups.push(() => { window.removeEventListener('resize', resizeHandler); });
|
||
}
|
||
function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) {
|
||
// 清理上一轮 tooltip 的事件订阅
|
||
if (window._tooltipCleanups) {
|
||
window._tooltipCleanups.forEach(fn => { try { fn(); } catch(e) {} });
|
||
}
|
||
window._tooltipCleanups = [];
|
||
|
||
window.debugMode = true;
|
||
// 初始化 U 显示状态(主/次周期分开控制)
|
||
const isShowUMain = $('#toggleUOnMain').is(':checked');
|
||
const isShowUElement = $('#toggleUOnElement').is(':checked');
|
||
window.showUOnMain = isShowUMain;
|
||
window.showUOnElement = isShowUElement;
|
||
if (!isShowUMain && !isShowUElement) {
|
||
// 隐藏时清空子图上的 U 标记
|
||
if (tvWidget.series && tvWidget.series.chanMacdLineSeries) {
|
||
try { tvWidget.series.chanMacdLineSeries.setMarkers([]); } catch (e) {}
|
||
}
|
||
if (tvWidget.series && tvWidget.series.chanMacdSignalSeries) {
|
||
try { tvWidget.series.chanMacdSignalSeries.setMarkers([]); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
// 添加买卖点悬浮提示元素
|
||
const tooltipElement = document.createElement('div');
|
||
tooltipElement.className = 'point-tooltip';
|
||
// document.body.appendChild(tooltipElement);
|
||
|
||
// 添加自定义十字线信息显示
|
||
const crosshairTooltip = document.createElement('div');
|
||
crosshairTooltip.className = 'crosshair-tooltip';
|
||
crosshairTooltip.style.position = 'absolute';
|
||
crosshairTooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||
crosshairTooltip.style.color = 'white';
|
||
crosshairTooltip.style.padding = '5px 10px';
|
||
crosshairTooltip.style.borderRadius = '4px';
|
||
crosshairTooltip.style.fontSize = '12px';
|
||
crosshairTooltip.style.zIndex = '1000';
|
||
crosshairTooltip.style.pointerEvents = 'none';
|
||
crosshairTooltip.style.display = 'none';
|
||
// document.body.appendChild(crosshairTooltip);
|
||
|
||
// 添加鼠标悬停事件显示提示
|
||
if (mainChart) {
|
||
const crosshairHandler = (param) => {
|
||
// 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果
|
||
if (param.time && param.point && volumeChart) {
|
||
try {
|
||
// 清除之前的十字线标记
|
||
const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line');
|
||
existingVolumeLines.forEach(line => line.remove());
|
||
const existingAtrLines = document.querySelectorAll('.atr-crosshair-line');
|
||
existingAtrLines.forEach(line => line.remove());
|
||
const existingMacdLines = document.querySelectorAll('.macd-crosshair-line');
|
||
existingMacdLines.forEach(line => line.remove());
|
||
const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line');
|
||
existingChanMacdLines.forEach(line => line.remove());
|
||
|
||
// 获取时间对应的坐标位置
|
||
const mainTimeCoordinate = mainChart.timeScale().timeToCoordinate(param.time);
|
||
if (mainTimeCoordinate !== null) {
|
||
// 获取主图容器的位置
|
||
const mainChartRect = mainChartContainer.getBoundingClientRect();
|
||
|
||
// 在交易量图上绘制垂直线
|
||
const volumeTimeCoordinate = volumeChart.timeScale().timeToCoordinate(param.time);
|
||
if (volumeTimeCoordinate !== null) {
|
||
const volumeChartRect = volumeChartContainer.getBoundingClientRect();
|
||
const volumeLine = document.createElement('div');
|
||
volumeLine.className = 'volume-crosshair-line';
|
||
volumeLine.style.position = 'fixed'; // 改为fixed定位
|
||
volumeLine.style.left = (volumeChartRect.left + volumeTimeCoordinate) + 'px';
|
||
volumeLine.style.top = volumeChartRect.top + 'px';
|
||
volumeLine.style.width = '1px';
|
||
volumeLine.style.height = volumeChartRect.height + 'px';
|
||
volumeLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
|
||
volumeLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
|
||
volumeLine.style.pointerEvents = 'none';
|
||
volumeLine.style.zIndex = '1000';
|
||
document.body.appendChild(volumeLine);
|
||
}
|
||
|
||
// 在ATR图上绘制垂直线
|
||
if (atrChart && atrChartContainer) {
|
||
const atrTimeCoordinate = atrChart.timeScale().timeToCoordinate(param.time);
|
||
if (atrTimeCoordinate !== null) {
|
||
const atrChartRect = atrChartContainer.getBoundingClientRect();
|
||
const atrLine = document.createElement('div');
|
||
atrLine.className = 'atr-crosshair-line';
|
||
atrLine.style.position = 'fixed'; // 改为fixed定位
|
||
atrLine.style.left = (atrChartRect.left + atrTimeCoordinate) + 'px';
|
||
atrLine.style.top = atrChartRect.top + 'px';
|
||
atrLine.style.width = '1px';
|
||
atrLine.style.height = atrChartRect.height + 'px';
|
||
atrLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
|
||
atrLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
|
||
atrLine.style.pointerEvents = 'none';
|
||
atrLine.style.zIndex = '1000';
|
||
document.body.appendChild(atrLine);
|
||
}
|
||
}
|
||
|
||
// 如果有MACD图,也在MACD图上绘制垂直线
|
||
if (showMacd && macdChart && macdChartContainer) {
|
||
const macdTimeCoordinate = macdChart.timeScale().timeToCoordinate(param.time);
|
||
if (macdTimeCoordinate !== null) {
|
||
const macdChartRect = macdChartContainer.getBoundingClientRect();
|
||
const macdLine = document.createElement('div');
|
||
macdLine.className = 'macd-crosshair-line';
|
||
macdLine.style.position = 'fixed'; // 改为fixed定位
|
||
macdLine.style.left = (macdChartRect.left + macdTimeCoordinate) + 'px';
|
||
macdLine.style.top = macdChartRect.top + 'px';
|
||
macdLine.style.width = '1px';
|
||
macdLine.style.height = macdChartRect.height + 'px';
|
||
macdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
|
||
macdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
|
||
macdLine.style.pointerEvents = 'none';
|
||
macdLine.style.zIndex = '1000';
|
||
document.body.appendChild(macdLine);
|
||
}
|
||
}
|
||
|
||
// 如果有ChanMACD图,也在ChanMACD图上绘制垂直线
|
||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
||
const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time);
|
||
if (chanMacdTimeCoordinate !== null) {
|
||
const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect();
|
||
const chanMacdLine = document.createElement('div');
|
||
chanMacdLine.className = 'chanmacd-crosshair-line';
|
||
chanMacdLine.style.position = 'fixed';
|
||
chanMacdLine.style.left = (chanMacdChartRect.left + chanMacdTimeCoordinate) + 'px';
|
||
chanMacdLine.style.top = chanMacdChartRect.top + 'px';
|
||
chanMacdLine.style.width = '1px';
|
||
chanMacdLine.style.height = chanMacdChartRect.height + 'px';
|
||
chanMacdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
|
||
chanMacdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)';
|
||
chanMacdLine.style.pointerEvents = 'none';
|
||
chanMacdLine.style.zIndex = '1000';
|
||
document.body.appendChild(chanMacdLine);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.debug('十字线同步出错:', e);
|
||
}
|
||
} else {
|
||
// 当十字线离开时,清除垂直线
|
||
try {
|
||
const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line');
|
||
existingVolumeLines.forEach(line => line.remove());
|
||
const existingAtrLines = document.querySelectorAll('.atr-crosshair-line');
|
||
existingAtrLines.forEach(line => line.remove());
|
||
const existingMacdLines = document.querySelectorAll('.macd-crosshair-line');
|
||
existingMacdLines.forEach(line => line.remove());
|
||
const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line');
|
||
existingChanMacdLines.forEach(line => line.remove());
|
||
} catch (e) {
|
||
console.debug('清除十字线时出错:', e);
|
||
}
|
||
}
|
||
|
||
if (param.time && param.point) {
|
||
const timeStr = param.time;
|
||
const markers = [...buyMarkers, ...sellMarkers].filter(m => m.time === timeStr);
|
||
|
||
// 同时检查分型标记
|
||
const fxMarkers = (window.fxMarkers || []).filter(m => m.time === timeStr);
|
||
const allMarkers = [...markers, ...fxMarkers];
|
||
|
||
// 显示时区调试信息
|
||
if (window.debugMode) {
|
||
const timezone = $('#timezone').val();
|
||
const formattedTime = formatTimeWithTimezone(timeStr * 1000, timezone);
|
||
|
||
// 获取当前价格 - 通过param.seriesPrices获取
|
||
let priceInfo = '';
|
||
if (param.seriesPrices && param.seriesPrices.size > 0) {
|
||
// 依次从当前可能的主系列中获取价格
|
||
if (tvWidget.series.candleSeries && param.seriesPrices.get(tvWidget.series.candleSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.candleSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
} else if (tvWidget.series.renkoSeries && param.seriesPrices.get(tvWidget.series.renkoSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.renkoSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
} else if (tvWidget.series.heikinSeries && param.seriesPrices.get(tvWidget.series.heikinSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.heikinSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
} else if (tvWidget.series.barSeries && param.seriesPrices.get(tvWidget.series.barSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.barSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
} else if (tvWidget.series.lineSeries && param.seriesPrices.get(tvWidget.series.lineSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.lineSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
} else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.areaSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
} else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.baselineSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
}
|
||
// 如果没有蜡烛图系列价格,尝试从区域图系列获取
|
||
else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.areaSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
}
|
||
// 如果没有蜡烛图系列价格,尝试从基线图系列获取
|
||
else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) {
|
||
const price = param.seriesPrices.get(tvWidget.series.baselineSeries);
|
||
priceInfo = `价格: ${price.toFixed(2)}`;
|
||
}
|
||
}
|
||
|
||
// 显示自定义时区工具提示,包含价格信息
|
||
crosshairTooltip.innerHTML = `<div style="font-weight:bold">时间: ${formattedTime}</div>` +
|
||
(priceInfo ? `<div>${priceInfo}</div>` : '');
|
||
crosshairTooltip.style.display = 'block';
|
||
crosshairTooltip.style.left = (param.point.x + 15) + 'px';
|
||
crosshairTooltip.style.top = (param.point.y - 30) + 'px';
|
||
}
|
||
|
||
if (allMarkers.length > 0) {
|
||
// 有买卖点或分型标记,显示自定义提示
|
||
const tooltips = allMarkers.map(m => m.tooltip).join('<br><hr style="margin: 5px 0;">');
|
||
tooltipElement.innerHTML = tooltips;
|
||
tooltipElement.style.display = 'block';
|
||
tooltipElement.style.left = (param.point.x + 15) + 'px';
|
||
tooltipElement.style.top = (param.point.y + 15) + 'px';
|
||
} else {
|
||
// 隐藏提示
|
||
tooltipElement.style.display = 'none';
|
||
}
|
||
} else {
|
||
// 隐藏提示
|
||
tooltipElement.style.display = 'none';
|
||
crosshairTooltip.style.display = 'none';
|
||
}
|
||
};
|
||
mainChart.subscribeCrosshairMove(crosshairHandler);
|
||
window._tooltipCleanups.push(() => {
|
||
try { mainChart.unsubscribeCrosshairMove(crosshairHandler); } catch(e) {}
|
||
});
|
||
|
||
// 处理图表缩放、平移等事件,隐藏提示
|
||
const hideTooltipHandler = () => {
|
||
tooltipElement.style.display = 'none';
|
||
crosshairTooltip.style.display = 'none';
|
||
};
|
||
mainChart.timeScale().subscribeVisibleTimeRangeChange(hideTooltipHandler);
|
||
window._tooltipCleanups.push(() => {
|
||
try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(hideTooltipHandler); } catch(e) {}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 辅助函数:使用指定时区格式化时间戳
|
||
function formatTimeWithTimezone(timestamp, timezone) {
|
||
try {
|
||
return new Date(timestamp).toLocaleString('zh-CN', {
|
||
timeZone: timezone,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
});
|
||
} catch (e) {
|
||
console.error('时区格式化错误:', e);
|
||
return new Date(timestamp).toLocaleString();
|
||
}
|
||
}
|