Files
Chan/web/static/js/app/chart_view.js
T
jackyu66gitandCursor 97e77847d0 fix(web): 开关缠论元素保留视窗;分周期 Trend 涨跌配色
本地重绘统一冻结视窗;次/次次周期 Trend 上涨下跌使用独立颜色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:56:55 +08:00

512 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* chart_view.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;
}
/** 实时基线是否过旧(仅自动刷新用来决定 recent vs 全量 live */
function isLiveBaselineStale(timeframe) {
if (!currentData || !Array.isArray(currentData.kline_data) || !currentData.kline_data.length) {
return true;
}
const last = currentData.kline_data[currentData.kline_data.length - 1];
let lastMs;
if (last.timestamp != null && last.timestamp !== '') {
lastMs = Number(last.timestamp);
} else {
lastMs = new Date(last.date).getTime();
}
if (Number.isNaN(lastMs)) return true;
const tfMs = (window.timeframeToMs && window.timeframeToMs(timeframe)) || (15 * 60 * 1000);
return (Date.now() - lastMs) > tfMs * 3;
}
/** 兼容旧名 */
function isChartBaselineStale(timeframe) {
return isLiveBaselineStale(timeframe);
}
function readChartFormContext() {
const dataSource = $('#dataSource').val() || 'crypto';
let symbol;
if (dataSource === 'crypto') {
symbol = $('#symbol').val() || 'BTC/USDT:USDT';
} else {
symbol = $('#astockSymbol').val() || '000001';
}
return {
dataSource: dataSource,
symbol: symbol,
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h',
timezone: $('#timezone').val() || 'Asia/Shanghai',
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m',
subSubTimeframe: $('#subSubTimeframe').val() || '',
startTimeMs: $('#start_time').val() ? new Date($('#start_time').val()).getTime() : null,
endTimeMs: $('#end_time').val() ? new Date($('#end_time').val()).getTime() : null
};
}
/** 当前展示用 K 线序列根数(主/小/次次周期与 freeze 逻辑一致) */
function getActiveKlineBarCount(data) {
data = data || (typeof currentData !== 'undefined' ? currentData : null);
if (!data) return 0;
const series = ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) ||
($('#elementPeriodKline').is(':checked') && data.element_kline_data) ||
data.kline_data;
return Array.isArray(series) ? series.length : 0;
}
/** 全量 init 前拍快照(周期切换等;不含 _preserveViewOnRefresh */
function snapshotPendingChartViewport() {
if (!tvWidget || !tvWidget.mainChart || typeof captureChartViewState !== 'function') {
return;
}
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
window._preserveViewBarCount = getActiveKlineBarCount();
} catch (e) {
window._pendingRestoreView = null;
window._preserveViewBarCount = 0;
}
}
/** 本地重绘(开关缠论元素 / K 线类型等):先冻结视窗再 init */
function reinitTradingViewPreservingViewport() {
snapshotPendingChartViewport();
initTradingView($('#symbol').val(), $('#timeframe').val());
}
/** 全量 init 前确保 _pendingRestoreView 与 bar 数齐全(refreshChart 等路径) */
function ensurePendingChartViewportBeforeInit() {
if (window._preserveViewOnRefresh) {
window._pendingRestoreView = window._preserveViewOnRefresh;
if (!window._preserveViewBarCount) {
window._preserveViewBarCount = getActiveKlineBarCount();
}
return;
}
if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
snapshotPendingChartViewport();
return;
}
if (window._pendingRestoreView && !window._preserveViewBarCount) {
window._preserveViewBarCount = getActiveKlineBarCount();
}
}
function freezeChartViewportBeforeRequest() {
try {
if (tvWidget && tvWidget.mainChart) {
const snap = captureChartViewState(tvWidget.mainChart);
window._preserveViewOnRefresh = snap;
// 全量 initTradingView 只认 _pendingRestoreView,须与增量冻结同步
window._pendingRestoreView = snap;
window._preserveViewBarCount = getActiveKlineBarCount();
console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, snap);
}
} catch (e) {
window._preserveViewOnRefresh = null;
window._pendingRestoreView = null;
window._preserveViewBarCount = 0;
}
}
function abortInFlightChartRequest() {
if (window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
try { window._analyzeXhr.abort(); } catch (e) {}
}
}
function applyAnalyzeSuccess(data, symbol, options) {
options = options || {};
const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
if (currentData) {
delete currentData.original_kline_data;
delete currentData.original_macd;
}
currentData = data;
window._lastChartSymbol = symbol;
window._lastFullAnalyzeAt = Date.now();
if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary();
}
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;
if (structureZonesOn || options.forceFullRebuild || symbolChanged || options.incremental === false) {
wantIncremental = false;
}
refreshChart(data, { incremental: wantIncremental });
}
/**
* 手动分析(按钮 / 首屏 / 切换参数)
* - 严格使用表单 start_time / end_time
* - 只走 /api/analyze,不做 recent 合并,不改结束时间为现在
*/
function analyzeChart(options) {
options = options || {};
$('#refreshLoadingSpinner').show();
const ctx = readChartFormContext();
console.log('手动分析:', ctx.symbol, ctx.timeframe, 'range', ctx.startTimeMs, '→', ctx.endTimeMs, options.reason || '');
if (!ctx.symbol) {
console.error('交易对/股票代码不能为空');
$('#refreshLoadingSpinner').hide();
return;
}
abortInFlightChartRequest();
freezeChartViewportBeforeRequest();
const requestId = ++lastRequestId;
window._analyzeXhr = $.ajax({
url: '/api/analyze',
data: {
symbol: ctx.symbol,
timeframe: ctx.timeframe,
timezone: ctx.timezone,
element_timeframe: ctx.elementTimeframe,
sub_sub_timeframe: ctx.subSubTimeframe || undefined,
start_time: ctx.startTimeMs,
end_time: ctx.endTimeMs,
elements_only: false,
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
},
success: function(data) {
$('#refreshLoadingSpinner').hide();
if (requestId !== lastRequestId) return;
applyAnalyzeSuccess(data, ctx.symbol, {
incremental: options.incremental !== undefined ? options.incremental : false,
forceFullRebuild: true
});
},
error: function(jqXHR, textStatus, errorThrown) {
$('#refreshLoadingSpinner').hide();
if (textStatus === 'abort') return;
console.error('分析失败:', errorThrown);
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
}
});
}
/**
* 自动刷新(仅启用自动刷新时)
* - 结束时间固定为当前时间(调用方先 updateEndTimeToNow
* - mode=recent/api/klines/recent 增量合并(不重算缠论)
* - mode=full/api/analyze 全量 livestart 用表单,end=现在)
*/
function autoRefreshChart(options) {
options = options || {};
const mode = options.mode === 'full' ? 'full' : 'recent';
$('#refreshLoadingSpinner').show();
const ctx = readChartFormContext();
console.log('自动刷新:', mode, ctx.symbol, ctx.timeframe, 'end=', ctx.endTimeMs);
if (!ctx.symbol) {
$('#refreshLoadingSpinner').hide();
return;
}
abortInFlightChartRequest();
freezeChartViewportBeforeRequest();
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 || '';
const canRecent = !!(
mode === 'recent' &&
chartsReady &&
hasBaseline &&
baselineSymbol &&
baselineSymbol === ctx.symbol &&
!isLiveBaselineStale(ctx.timeframe)
);
if (canRecent) {
console.log('自动刷新 → /api/klines/recent limit=2');
window._analyzeXhr = $.ajax({
url: '/api/klines/recent',
data: {
symbol: ctx.symbol,
timeframe: ctx.timeframe,
limit: 2,
element_timeframe: ctx.elementTimeframe || undefined,
sub_sub_timeframe: ctx.subSubTimeframe || undefined
},
success: function(partial) {
$('#refreshLoadingSpinner').hide();
if (requestId !== lastRequestId) return;
if (!partial || !Array.isArray(partial.kline_data)) {
console.warn('recent 无效,改走 live 全量');
autoRefreshChart({ mode: 'full', 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 失败,改走 live 全量:', errorThrown);
autoRefreshChart({ mode: 'full', reason: 'recent-error-fallback' });
}
});
return;
}
console.log('自动刷新 → /api/analyze (live)');
window._analyzeXhr = $.ajax({
url: '/api/analyze',
data: {
symbol: ctx.symbol,
timeframe: ctx.timeframe,
timezone: ctx.timezone,
element_timeframe: ctx.elementTimeframe,
sub_sub_timeframe: ctx.subSubTimeframe || undefined,
start_time: ctx.startTimeMs,
end_time: ctx.endTimeMs,
elements_only: false,
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
},
success: function(data) {
$('#refreshLoadingSpinner').hide();
if (requestId !== lastRequestId) return;
applyAnalyzeSuccess(data, ctx.symbol, {
incremental: options.incremental === true,
forceFullRebuild: options.forceFullRebuild !== false && options.incremental !== true
});
},
error: function(jqXHR, textStatus, errorThrown) {
$('#refreshLoadingSpinner').hide();
if (textStatus === 'abort') return;
console.error('自动刷新分析失败:', errorThrown);
}
});
}
/** 兼容旧调用:手动走 analyzeChartfromAutoRefresh 转 autoRefreshChart */
function updateChart(options) {
options = options || {};
if (options.fromAutoRefresh) {
console.warn('updateChart(fromAutoRefresh) 已废弃,请改用 autoRefreshChart');
autoRefreshChart({
mode: options.fullAnalyze ? 'full' : 'recent',
incremental: options.incremental,
forceFullRebuild: options.incremental === false,
reason: options.reason
});
return;
}
analyzeChart(options);
}
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
};
}
/** 将可见时间窗口限制在真实 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) {
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
options = options || {};
const validCharts = charts.filter(c => c && c.timeScale);
if (validCharts.length === 0) return;
const mainChart = validCharts[0];
const incremental = !!options.incremental;
const oldBarCount = options.oldBarCount || window._preserveViewBarCount || 0;
const newBarCount = options.newBarCount || 0;
const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0;
const applyLogical = function (lr) {
if (!lr || lr.from === undefined || lr.to === undefined) return false;
let from = lr.from;
let to = lr.to;
if (newBarCount > 0) {
const span = Math.max(1, to - from);
const maxTo = newBarCount - 1 + 8;
if (to > maxTo) {
to = maxTo;
from = to - span;
}
if (from < -8) {
from = -8;
to = from + span;
}
lr = { from: from, to: to };
}
let ok = false;
validCharts.forEach(c => {
try {
c.timeScale().setVisibleLogicalRange(lr);
ok = true;
} catch (e) {}
});
return ok;
};
const applyVisible = function () {
if (!viewState.visibleRange ||
viewState.visibleRange.from === undefined ||
viewState.visibleRange.to === undefined) {
return false;
}
let vr = viewState.visibleRange;
if (options.firstBarTime != null && options.lastBarTime != null) {
vr = clampVisibleRangeToBarTimes(vr, options.firstBarTime, options.lastBarTime);
}
let ok = false;
validCharts.forEach(c => {
try {
c.timeScale().setVisibleRange(vr);
ok = true;
} catch (e) {}
});
return ok;
};
const applyScroll = function () {
if (typeof viewState.scrollPosition !== 'number') return false;
try {
const pos = viewState.scrollPosition + barDelta;
mainChart.timeScale().scrollToPosition(pos, false);
const lrNow = mainChart.timeScale().getVisibleLogicalRange();
if (lrNow) {
validCharts.forEach(c => {
try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {}
});
return true;
}
} catch (e) {}
return false;
};
const restorePosition = function () {
if (incremental) {
// 尾部增量:scroll+barDelta 最稳;全量重建勿先 scroll(中间段会锚到最右)
if (applyScroll()) return true;
if (applyLogical(viewState.logicalRange)) return true;
return applyVisible();
}
// 全量重建:logical → clamped time → scroll
if (applyLogical(viewState.logicalRange)) return true;
if (applyVisible()) return true;
return applyScroll();
};
restorePosition();
// barSpacing 写在位置之后会按右缘重锚,故放最后并再扳一次位置
if (!options.skipBarSpacing && typeof viewState.barSpacing === 'number') {
validCharts.forEach(c => {
try {
c.timeScale().applyOptions({ barSpacing: viewState.barSpacing });
} catch (e) {}
});
restorePosition();
}
}