fix: TradingView K线图表并修复品种推荐为空。

- 行情页改用 Lightweight Charts 标准蜡烛图(红跌绿涨)
- 修复 fee_rates 缺 source 列导致推荐刷新失败
- 空缓存自动重试,持仓页实时兜底计算推荐列表

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 12:33:49 +08:00
parent 074551490f
commit 32f1fa2c66
8 changed files with 458 additions and 527 deletions
+315 -487
View File
@@ -3,14 +3,36 @@
var emptyEl = document.getElementById('market-chart-empty');
var wrapEl = document.getElementById('market-chart-wrap');
var chart = null;
var candleSeries = null;
var volumeSeries = null;
var areaSeries = null;
var ma21Series = null;
var ma55Series = null;
var prevCloseLine = null;
var resizeObs = null;
var currentPeriod = '15m';
var currentChartMode = '';
var klineSource = null;
var streamActive = false;
var reconnectTimer = null;
var lastData = null;
var lastPrevClose = null;
var chartOpts = { prevClose: false, ma: false, gapDay: false };
var dataZoomBound = false;
var followingLatest = true;
var DEFAULT_VISIBLE_BARS = 80;
var PERIOD_SECONDS = {
timeshare: 60,
'1m': 60,
'2m': 120,
'5m': 300,
'15m': 900,
'1h': 3600,
'2h': 7200,
'4h': 14400,
d: 86400,
w: 604800,
};
function getSymbol() {
var hidden = document.getElementById('market-symbol-hidden');
@@ -31,17 +53,14 @@
function themeColors() {
var dark = document.documentElement.getAttribute('data-theme') !== 'light';
return {
bg: dark ? '#0a0c14' : '#f4f7fc',
bg: dark ? '#0a0c14' : '#ffffff',
text: dark ? '#a8b0c8' : '#5c6578',
title: dark ? '#e8eaf6' : '#1a2233',
grid: dark ? '#1a2038' : '#e2e8f0',
up: dark ? '#4cd97f' : '#15803d',
down: dark ? '#ff6b7a' : '#dc2626',
line: dark ? '#4cc2ff' : '#2563eb',
area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)',
slider: dark ? '#1e2640' : '#cbd5e1',
sliderFill: dark ? '#4cc2ff' : '#2563eb',
ma21: dark ? '#ffb347' : '#d97706',
grid: dark ? '#1e2640' : '#e8edf5',
up: dark ? '#26a69a' : '#089981',
down: dark ? '#ef5350' : '#f23645',
line: dark ? '#4cc2ff' : '#2962ff',
areaTop: dark ? 'rgba(76,194,255,0.28)' : 'rgba(41,98,255,0.22)',
ma21: dark ? '#ffb347' : '#f7931a',
ma55: dark ? '#c084fc' : '#7c3aed',
prevClose: dark ? '#fbbf24' : '#b45309',
};
@@ -63,484 +82,272 @@
return false;
}
function calcMA(period, closes) {
var result = [];
for (var i = 0; i < closes.length; i++) {
if (i < period - 1) {
result.push(null);
continue;
function barUnixTime(bar) {
if (bar.timestamp) return Math.floor(bar.timestamp / 1000);
if (bar.time) {
var d = new Date(String(bar.time).replace(' ', 'T'));
if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
}
return null;
}
function prepareBars(bars, periodKey) {
var out = [];
var gapDay = chartOpts.gapDay;
var seen = {};
var gapBase = null;
var step = PERIOD_SECONDS[periodKey] || 60;
for (var i = 0; i < bars.length; i++) {
var b = bars[i];
var o = Number(b.open);
var h = Number(b.high);
var l = Number(b.low);
var c = Number(b.close);
if (!isFinite(o) || !isFinite(c)) continue;
if (!isFinite(h)) h = Math.max(o, c);
if (!isFinite(l)) l = Math.min(o, c);
h = Math.max(h, o, c);
l = Math.min(l, o, c);
var t;
if (gapDay) {
if (gapBase == null) {
gapBase = b.timestamp ? Math.floor(b.timestamp / 1000) : 946684800;
}
t = gapBase + out.length * step;
} else {
t = barUnixTime(b);
}
if (t == null || seen[t]) continue;
seen[t] = true;
out.push({
time: t,
open: o,
high: h,
low: l,
close: c,
volume: Number(b.volume) || 0,
rawTime: b.time,
});
}
return out;
}
function calcMA(period, bars) {
var result = [];
for (var i = 0; i < bars.length; i++) {
if (i < period - 1) continue;
var sum = 0;
for (var j = 0; j < period; j++) sum += closes[i - j];
result.push(+(sum / period).toFixed(4));
for (var j = 0; j < period; j++) sum += bars[i - j].close;
result.push({ time: bars[i].time, value: +(sum / period).toFixed(4) });
}
return result;
}
function findMaCrosses(ma21, ma55) {
var points = [];
for (var i = 1; i < ma21.length; i++) {
if (ma21[i] == null || ma55[i] == null || ma21[i - 1] == null || ma55[i - 1] == null) continue;
var prev = ma21[i - 1] - ma55[i - 1];
var curr = ma21[i] - ma55[i];
if (prev <= 0 && curr > 0) points.push({ i: i, type: 'golden' });
if (prev >= 0 && curr < 0) points.push({ i: i, type: 'death' });
function destroyChart() {
if (resizeObs) {
resizeObs.disconnect();
resizeObs = null;
}
return points;
}
function getDefaultZoomStart() {
return currentPeriod === 'timeshare' ? 60 : 75;
}
function getZoomRange() {
if (!chart) return { start: getDefaultZoomStart(), end: 100 };
var opt = chart.getOption();
if (opt && opt.dataZoom && opt.dataZoom.length) {
var z = opt.dataZoom[0];
if (z.start != null && z.end != null) return { start: z.start, end: z.end };
if (chart) {
chart.remove();
chart = null;
}
return { start: getDefaultZoomStart(), end: 100 };
candleSeries = null;
volumeSeries = null;
areaSeries = null;
ma21Series = null;
ma55Series = null;
prevCloseLine = null;
currentChartMode = '';
}
function visibleIndices(bars, zoom) {
var n = bars.length;
if (!n) return { start: 0, end: 0 };
var start = Math.floor(n * zoom.start / 100);
var end = Math.min(n - 1, Math.ceil(n * zoom.end / 100) - 1);
if (end < start) end = start;
return { start: start, end: end };
}
function computeVisibleHL(bars, startIdx, endIdx) {
var maxH = -Infinity;
var minL = Infinity;
var maxI = startIdx;
var minI = startIdx;
for (var i = startIdx; i <= endIdx; i++) {
var b = bars[i];
if (!b) continue;
if (b.high > maxH) { maxH = b.high; maxI = i; }
if (b.low < minL) { minL = b.low; minI = i; }
}
if (maxH === -Infinity) return null;
return { maxH: maxH, minL: minL, maxI: maxI, minI: minI };
}
function buildHLMarkPoint(hl, c) {
if (!hl) return { data: [] };
return {
symbol: 'circle',
symbolSize: 6,
data: [
{
name: '高',
xAxis: hl.maxI,
yAxis: hl.maxH,
value: hl.maxH.toFixed(2),
itemStyle: { color: c.up },
label: {
show: true,
formatter: '高 ' + hl.maxH.toFixed(2),
color: c.up,
fontSize: 11,
position: 'top',
},
},
{
name: '低',
xAxis: hl.minI,
yAxis: hl.minL,
value: hl.minL.toFixed(2),
itemStyle: { color: c.down },
label: {
show: true,
formatter: '低 ' + hl.minL.toFixed(2),
color: c.down,
fontSize: 11,
position: 'bottom',
},
},
],
};
}
function buildMaCrossMarkPoint(crosses, bars, c) {
if (!crosses.length) return { data: [] };
return {
symbol: 'pin',
symbolSize: 28,
data: crosses.map(function (p) {
var b = bars[p.i];
return {
name: p.type === 'golden' ? '金叉' : '死叉',
xAxis: p.i,
yAxis: b ? b.close : 0,
itemStyle: { color: p.type === 'golden' ? c.up : c.down },
label: {
show: true,
formatter: p.type === 'golden' ? '金叉' : '死叉',
fontSize: 10,
color: p.type === 'golden' ? c.up : c.down,
},
};
}),
};
}
function buildPrevCloseMarkLine(prevClose, c) {
if (prevClose == null) return { data: [] };
var v = Number(prevClose);
return {
silent: true,
symbol: 'none',
lineStyle: { color: c.prevClose, type: 'dashed', width: 1 },
label: {
show: true,
formatter: '昨收 ' + v.toFixed(2),
color: c.prevClose,
fontSize: 10,
},
data: [{ yAxis: v }],
};
}
function initChart() {
if (!chartEl || !window.echarts) return;
chart = echarts.init(chartEl);
window.addEventListener('resize', function () {
if (chart) chart.resize();
});
document.addEventListener('click', function (e) {
if (e.target.closest('[data-theme-pick]')) {
setTimeout(function () {
if (chart && lastData) renderChart(lastData, true);
}, 100);
}
});
}
function bindDataZoomHL() {
if (!chart || dataZoomBound) return;
dataZoomBound = true;
chart.on('dataZoom', function () {
updateVisibleHLMark();
});
}
function updateVisibleHLMark() {
if (!chart || !lastData || !lastData.bars) return;
var bars = lastData.bars;
var zoom = getZoomRange();
var idx = visibleIndices(bars, zoom);
var hl = computeVisibleHL(bars, idx.start, idx.end);
function buildChart(mode) {
destroyChart();
if (!chartEl || !window.LightweightCharts) return;
var c = themeColors();
chart.setOption({
series: [{ id: 'main', markPoint: buildHLMarkPoint(hl, c) }],
});
}
function getDataZoom(c, preserve) {
var defStart = getDefaultZoomStart();
var xZoom = {
type: 'inside',
id: 'dzInsideX',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
preventDefaultMouseMove: true,
minSpan: 2,
};
var yZoom = {
type: 'inside',
id: 'dzInsideY',
yAxisIndex: [0],
orient: 'vertical',
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
preventDefaultMouseMove: true,
};
var slider = {
type: 'slider',
id: 'dzSlider',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
height: 22,
bottom: 4,
borderColor: c.grid,
backgroundColor: c.bg,
fillerColor: c.area,
handleStyle: { color: c.sliderFill },
dataBackground: {
lineStyle: { color: c.grid, opacity: 0.35 },
areaStyle: { color: c.area },
var w = chartEl.clientWidth || 600;
var h = chartEl.clientHeight || 400;
chart = LightweightCharts.createChart(chartEl, {
width: w,
height: h,
layout: {
background: { type: 'solid', color: c.bg },
textColor: c.text,
fontSize: 11,
},
textStyle: { color: c.text, fontSize: 10 },
filterMode: 'none',
brushSelect: false,
};
var zoom = [xZoom, yZoom, slider];
if (preserve && chart) {
var opt = chart.getOption();
if (opt && opt.dataZoom) {
opt.dataZoom.forEach(function (z) {
if (!z.id) return;
var target = zoom.find(function (t) { return t.id === z.id; });
if (target && z.start != null && z.end != null) {
target.start = z.start;
target.end = z.end;
}
grid: {
vertLines: { color: c.grid, style: 1 },
horzLines: { color: c.grid, style: 1 },
},
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
vertLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid },
horzLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid },
},
rightPriceScale: {
borderColor: c.grid,
scaleMargins: mode === 'line' ? { top: 0.08, bottom: 0.08 } : { top: 0.05, bottom: 0.22 },
},
timeScale: {
borderColor: c.grid,
timeVisible: true,
secondsVisible: false,
rightOffset: 8,
barSpacing: 10,
minBarSpacing: 4,
fixLeftEdge: false,
fixRightEdge: false,
},
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true },
localization: { locale: 'zh-CN' },
});
if (mode === 'line') {
areaSeries = chart.addAreaSeries({
lineColor: c.line,
topColor: c.areaTop,
bottomColor: 'rgba(0,0,0,0)',
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: true,
});
} else {
candleSeries = chart.addCandlestickSeries({
upColor: c.up,
downColor: c.down,
borderVisible: true,
borderUpColor: c.up,
borderDownColor: c.down,
wickUpColor: c.up,
wickDownColor: c.down,
priceLineVisible: false,
lastValueVisible: true,
});
volumeSeries = chart.addHistogramSeries({
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
lastValueVisible: false,
priceLineVisible: false,
});
chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
borderVisible: false,
});
if (chartOpts.ma) {
ma21Series = chart.addLineSeries({
color: c.ma21,
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
});
ma55Series = chart.addLineSeries({
color: c.ma55,
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
});
}
}
return zoom;
chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
if (!chart) return;
var range = chart.timeScale().getVisibleLogicalRange();
if (!range || !lastData || !lastData.preparedBars) return;
var total = lastData.preparedBars.length;
followingLatest = range.to >= total - 2;
});
resizeObs = new ResizeObserver(function () {
if (!chart || !chartEl) return;
chart.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight });
});
resizeObs.observe(chartEl);
currentChartMode = mode;
}
function isFollowingLatest() {
var z = getZoomRange();
return z.end >= 98;
}
function mapSeriesData(bars, values, gapDay) {
if (!gapDay) return values;
return bars.map(function (b, i) {
var v = values[i];
if (v == null || b.timestamp == null) return v;
return [b.timestamp, v];
function applyPrevCloseLine(price) {
if (!candleSeries || currentChartMode !== 'candle') return;
if (prevCloseLine) {
candleSeries.removePriceLine(prevCloseLine);
prevCloseLine = null;
}
if (!chartOpts.prevClose || price == null || !isFinite(Number(price))) return;
var c = themeColors();
prevCloseLine = candleSeries.createPriceLine({
price: Number(price),
color: c.prevClose,
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dashed,
axisLabelVisible: true,
title: '昨收',
});
}
function renderChart(data, preserveZoom) {
if (!chart) return;
function setVisibleRange(prepared, preserve) {
if (!chart || !prepared.length) return;
var ts = chart.timeScale();
if (preserve && followingLatest) {
var span = DEFAULT_VISIBLE_BARS;
try {
var cur = ts.getVisibleLogicalRange();
if (cur) span = Math.max(20, cur.to - cur.from);
} catch (e) { /* ignore */ }
ts.setVisibleLogicalRange({
from: Math.max(0, prepared.length - span),
to: prepared.length + 4,
});
return;
}
if (preserve) return;
var show = Math.min(DEFAULT_VISIBLE_BARS, prepared.length);
ts.setVisibleLogicalRange({
from: Math.max(0, prepared.length - show),
to: prepared.length + 4,
});
}
function renderChart(data, preserveRange) {
if (!chartEl || !window.LightweightCharts) return;
lastData = data;
if (data.prev_close != null) lastPrevClose = data.prev_close;
var c = themeColors();
var bars = data.bars || [];
var times = bars.map(function (b) { return b.time; });
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
var gapDay = chartOpts.gapDay;
var followLatest = preserveZoom && isFollowingLatest();
var dataZoom = getDataZoom(c, preserveZoom);
var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end };
var vIdx = visibleIndices(bars, zoom);
var hl = computeVisibleHL(bars, vIdx.start, vIdx.end);
var closes = bars.map(function (b) { return b.close; });
var ma21 = chartOpts.ma ? calcMA(21, closes) : null;
var ma55 = chartOpts.ma ? calcMA(55, closes) : null;
var crosses = chartOpts.ma ? findMaCrosses(ma21, ma55) : [];
var prevCloseVal = lastPrevClose != null ? lastPrevClose : data.prev_close;
var showPrev = chartOpts.prevClose && prevCloseVal != null;
var mode = isLine ? 'line' : 'candle';
if (!chart || currentChartMode !== mode) buildChart(mode);
if (!chart) return;
var grids = [
{ left: 56, right: 24, top: 44, height: '50%' },
{ left: 56, right: 24, top: '66%', height: '14%' },
];
var prepared = prepareBars(data.bars || [], data.period || currentPeriod);
data.preparedBars = prepared;
if (!prepared.length) return;
var xAxisType = gapDay ? 'time' : 'category';
var xAxis0 = {
type: xAxisType,
gridIndex: 0,
boundaryGap: gapDay ? false : true,
axisLabel: { color: c.text, fontSize: 10 },
axisLine: { lineStyle: { color: c.grid } },
splitLine: { show: false },
};
var xAxis1 = {
type: xAxisType,
gridIndex: 1,
boundaryGap: gapDay ? false : true,
axisLabel: { show: false },
axisLine: { lineStyle: { color: c.grid } },
splitLine: { show: false },
};
if (!gapDay) {
xAxis0.data = times;
xAxis1.data = times;
}
var base = {
backgroundColor: c.bg,
animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
grid: grids,
xAxis: [xAxis0, xAxis1],
yAxis: [
{
scale: true,
gridIndex: 0,
splitLine: { show: false },
axisLabel: { color: c.text },
},
{
scale: true,
gridIndex: 1,
splitLine: { show: false },
axisLabel: { color: c.text, fontSize: 10 },
splitNumber: 2,
},
],
};
if (!preserveZoom) {
base.dataZoom = dataZoom;
}
var series = [];
var mainMark = {
markPoint: buildHLMarkPoint(hl, c),
};
if (showPrev) mainMark.markLine = buildPrevCloseMarkLine(prevCloseVal, c);
if (chartOpts.ma && crosses.length) {
var crossMp = buildMaCrossMarkPoint(crosses, bars, c);
mainMark.markPoint.data = mainMark.markPoint.data.concat(crossMp.data);
}
if (isLine) {
var lineData = mapSeriesData(bars, closes, gapDay);
series.push({
id: 'main',
name: '价格',
type: 'line',
data: lineData,
smooth: true,
showSymbol: false,
lineStyle: { width: 1.5, color: c.line },
areaStyle: { color: c.area },
xAxisIndex: 0,
yAxisIndex: 0,
markPoint: mainMark.markPoint,
markLine: mainMark.markLine,
});
if (mode === 'line') {
areaSeries.setData(prepared.map(function (b) {
return { time: b.time, value: b.close };
}));
} else {
var candle = bars.map(function (b) {
if (gapDay && b.timestamp) {
return [b.timestamp, b.open, b.close, b.low, b.high];
}
return [b.open, b.close, b.low, b.high];
});
series.push({
id: 'main',
name: 'K线',
type: 'candlestick',
data: candle,
barMaxWidth: 14,
barMinWidth: 3,
itemStyle: {
color: c.up,
color0: c.down,
borderColor: c.up,
borderColor0: c.down,
},
xAxisIndex: 0,
yAxisIndex: 0,
markPoint: mainMark.markPoint,
markLine: mainMark.markLine,
});
}
if (chartOpts.ma && ma21 && ma55) {
series.push({
id: 'ma21',
name: 'MA21',
type: 'line',
data: mapSeriesData(bars, ma21, gapDay),
smooth: true,
showSymbol: false,
lineStyle: { width: 1, color: c.ma21 },
xAxisIndex: 0,
yAxisIndex: 0,
});
series.push({
id: 'ma55',
name: 'MA55',
type: 'line',
data: mapSeriesData(bars, ma55, gapDay),
smooth: true,
showSymbol: false,
lineStyle: { width: 1, color: c.ma55 },
xAxisIndex: 0,
yAxisIndex: 0,
});
}
var vols = bars.map(function (b) {
var up = b.close >= b.open;
var val = {
value: b.volume,
itemStyle: { color: up ? c.up : c.down, opacity: 0.65 },
};
if (gapDay && b.timestamp) {
return { value: [b.timestamp, b.volume], itemStyle: val.itemStyle };
candleSeries.setData(prepared.map(function (b) {
return { time: b.time, open: b.open, high: b.high, low: b.low, close: b.close };
}));
volumeSeries.setData(prepared.map(function (b) {
var up = b.close >= b.open;
var c = themeColors();
return {
time: b.time,
value: b.volume,
color: up ? c.up : c.down,
};
}));
if (chartOpts.ma && ma21Series && ma55Series) {
ma21Series.setData(calcMA(21, prepared));
ma55Series.setData(calcMA(55, prepared));
}
return val;
});
series.push({
id: 'volume',
name: '成交量',
type: 'bar',
data: vols,
xAxisIndex: 1,
yAxisIndex: 1,
});
if (!isLine) {
base.tooltip = {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function (params) {
var idx = params[0] && params[0].dataIndex;
if (idx == null || !bars[idx]) return '';
var b = bars[idx];
var lines = [b.time, '开 ' + b.open, '高 ' + b.high, '低 ' + b.low, '收 ' + b.close, '量 ' + b.volume];
if (ma21 && ma21[idx] != null) lines.push('MA21 ' + ma21[idx]);
if (ma55 && ma55[idx] != null) lines.push('MA55 ' + ma55[idx]);
return lines.join('<br/>');
},
};
applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close);
}
if (preserveZoom) {
chart.setOption(Object.assign(base, { series: series }), false);
} else {
chart.setOption(Object.assign(base, { series: series }), true);
dataZoomBound = false;
}
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
chart.setOption({
title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } },
legend: chartOpts.ma ? {
data: ['MA21', 'MA55'],
top: 8,
right: 12,
textStyle: { color: c.text, fontSize: 10 },
} : { show: false },
});
if (followLatest) {
var span = zoom.end - zoom.start;
chart.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 0,
start: Math.max(0, 100 - span),
end: 100,
});
chart.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 2,
start: Math.max(0, 100 - span),
end: 100,
});
}
bindDataZoomHL();
setVisibleRange(prepared, !!preserveRange);
}
function periodLabel(key) {
@@ -603,9 +410,9 @@
src = ' · ' + klineSourceLabel(lastData.source);
}
if (isTradingSession()) {
el.textContent = '交易中 · 后台刷新 · SSE 推送(约1秒)' + src;
el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src;
} else {
el.textContent = 'SSE 推送 · 非交易时段低频刷新' + src;
el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src;
}
}
@@ -632,7 +439,9 @@
if (data.prev_close != null) {
lastPrevClose = data.prev_close;
updatePrevCloseDisplay(data.prev_close);
if (chartOpts.prevClose && lastData) renderChart(lastData, true);
if (chartOpts.prevClose && lastData) {
applyPrevCloseLine(data.prev_close);
}
}
}
@@ -674,6 +483,7 @@
klineSource = new EventSource('/api/kline/stream?' + q);
streamActive = true;
followingLatest = true;
updateRefreshHint(false);
klineSource.addEventListener('kline', function (e) {
@@ -725,28 +535,22 @@
function shiftDataZoom(delta) {
if (!chart) return;
var opt = chart.getOption();
if (!opt || !opt.dataZoom || !opt.dataZoom.length) return;
var z = opt.dataZoom[0];
var span = (z.end - z.start) || 20;
var newSpan = Math.max(5, Math.min(100, span + delta));
var center = (z.start + z.end) / 2;
var start = Math.max(0, center - newSpan / 2);
var end = Math.min(100, center + newSpan / 2);
if (end - start < newSpan) {
if (start === 0) end = newSpan;
else start = end - newSpan;
}
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: end });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: end });
var ts = chart.timeScale();
var range = ts.getVisibleLogicalRange();
if (!range) return;
var span = range.to - range.from;
var newSpan = Math.max(15, span + delta);
var center = (range.from + range.to) / 2;
ts.setVisibleLogicalRange({
from: center - newSpan / 2,
to: center + newSpan / 2,
});
}
function resetDataZoom() {
if (!chart) return;
var start = getDefaultZoomStart();
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: 100 });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: 100 });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 });
if (!chart || !lastData || !lastData.preparedBars) return;
followingLatest = true;
setVisibleRange(lastData.preparedBars, false);
}
function bindPeriodTabs() {
@@ -758,6 +562,7 @@
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
btn.classList.add('active');
currentPeriod = btn.getAttribute('data-period') || '15m';
followingLatest = true;
if (getSymbol()) loadKline(true);
});
}
@@ -766,8 +571,8 @@
var zoomIn = document.getElementById('chart-zoom-in');
var zoomOut = document.getElementById('chart-zoom-out');
var zoomReset = document.getElementById('chart-zoom-reset');
if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-12); });
if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(12); });
if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-20); });
if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(20); });
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
}
@@ -778,29 +583,47 @@
if (prevCb) {
prevCb.addEventListener('change', function () {
chartOpts.prevClose = prevCb.checked;
if (lastData) renderChart(lastData, true);
if (lastData) {
applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : lastData.prev_close);
}
});
}
if (maCb) {
maCb.addEventListener('change', function () {
chartOpts.ma = maCb.checked;
if (lastData) renderChart(lastData, true);
if (lastData) {
destroyChart();
renderChart(lastData, false);
}
});
}
if (gapCb) {
gapCb.addEventListener('change', function () {
chartOpts.gapDay = gapCb.checked;
followingLatest = true;
if (lastData) renderChart(lastData, false);
});
}
}
document.addEventListener('DOMContentLoaded', function () {
initChart();
if (!window.LightweightCharts) {
if (emptyEl) emptyEl.textContent = '图表库加载失败,请刷新页面';
return;
}
bindPeriodTabs();
bindZoomButtons();
bindChartOptions();
document.addEventListener('click', function (e) {
if (e.target.closest('[data-theme-pick]') && lastData) {
setTimeout(function () {
destroyChart();
renderChart(lastData, false);
}, 80);
}
});
var active = document.querySelector('.period-tab.active');
if (active) currentPeriod = active.getAttribute('data-period') || '15m';
@@ -812,6 +635,8 @@
if (input) {
input.addEventListener('symbol-selected', function () {
lastPrevClose = null;
lastData = null;
destroyChart();
updatePrevCloseDisplay(null);
loadKline(true);
});
@@ -823,6 +648,9 @@
updateRefreshHint(false);
}
window.addEventListener('beforeunload', stopKlineStream);
window.addEventListener('beforeunload', function () {
stopKlineStream();
destroyChart();
});
});
})();