/* 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); // 自动刷新常态:只拉最近 2 根;fullAnalyze(约每 1 分钟)走全量 analyze 更新缠论 const useRecentTail = !!(options.fromAutoRefresh && !options.fullAnalyze && chartsReady && hasBaseline); 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; } // 保存当前数据 if (currentData) { // 覆盖前断开旧引用,帮助GC尽快回收 delete currentData.original_kline_data; delete currentData.original_macd; } currentData = data; window._lastFullAnalyzeAt = Date.now(); if (typeof renderWyckoffCycleSummary === 'function') { renderWyckoffCycleSummary(); } // 有图则增量;笔/段/中枢/结构区只在全量 init 绘制,fullAnalyze 必须重建 const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); const structureZonesOn = $('#showMainStructureZone').is(':checked'); let wantIncremental = options.incremental !== undefined ? !!options.incremental : (ready || !!options.fromAutoRefresh); if (structureZonesOn || options.fullAnalyze) { 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) {} }); } } // 初始化图表