LWC 折线无法画真竖线;增量刷新时用坐标采样补刷竖边,避免与横边脱节。 Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
/* 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 || {};
|
|
// 只显示旋转加载图标
|
|
$('#refreshLoadingSpinner').show();
|
|
|
|
// 获取参数
|
|
const dataSource = $('#dataSource').val() || 'crypto';
|
|
let symbol;
|
|
if (dataSource === 'crypto') {
|
|
symbol = $('#symbol').val() || 'BTC/USDT:USDT';
|
|
} 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) {
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 手动 / 首拉:全量 analyze
|
|
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,
|
|
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 });
|
|
},
|
|
error: function(jqXHR, textStatus, errorThrown) {
|
|
// 隐藏加载图标
|
|
$('#refreshLoadingSpinner').hide();
|
|
if (textStatus === 'abort') {
|
|
return;
|
|
}
|
|
|
|
// 显示错误信息
|
|
console.error('加载数据失败:', errorThrown);
|
|
// 自动刷新失败不弹窗打扰
|
|
if (!options.fromAutoRefresh) {
|
|
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
function captureChartViewState(chart) {
|
|
if (!chart || !chart.timeScale) return null;
|
|
const ts = chart.timeScale();
|
|
const tsOptions = ts.options ? ts.options() : {};
|
|
return {
|
|
barSpacing: tsOptions.barSpacing,
|
|
rightOffset: tsOptions.rightOffset,
|
|
scrollPosition: ts.scrollPosition ? ts.scrollPosition() : null,
|
|
visibleRange: ts.getVisibleRange ? ts.getVisibleRange() : null,
|
|
logicalRange: ts.getVisibleLogicalRange ? ts.getVisibleLogicalRange() : null
|
|
};
|
|
}
|
|
|
|
function restoreChartViewState(charts, viewState) {
|
|
// 全量重建备用:先缩放,再位置;不要在位置前写 rightOffset(会右边缘锚定)
|
|
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
|
|
const validCharts = charts.filter(c => c && c.timeScale);
|
|
if (validCharts.length === 0) return;
|
|
|
|
validCharts.forEach(c => {
|
|
try {
|
|
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);
|
|
restored = true;
|
|
} catch (e) {}
|
|
});
|
|
}
|
|
|
|
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
|
|
validCharts.forEach(c => {
|
|
try {
|
|
c.timeScale().setVisibleRange(viewState.visibleRange);
|
|
restored = true;
|
|
} catch (e) {}
|
|
});
|
|
}
|
|
|
|
if (!restored && typeof viewState.scrollPosition === 'number') {
|
|
validCharts.forEach(c => {
|
|
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
|
|
});
|
|
}
|
|
}
|
|
// 初始化图表
|