de6815d481
Co-authored-by: Cursor <cursoragent@cursor.com>
827 lines
29 KiB
JavaScript
827 lines
29 KiB
JavaScript
(function () {
|
|
var chartEl = document.getElementById('market-chart');
|
|
var emptyEl = document.getElementById('market-chart-empty');
|
|
var wrapEl = document.getElementById('market-chart-wrap');
|
|
var chart = null;
|
|
var currentPeriod = '15m';
|
|
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;
|
|
|
|
function getSymbol() {
|
|
var hidden = document.getElementById('market-symbol-hidden');
|
|
var input = document.getElementById('market-symbol-input');
|
|
if (hidden && hidden.value) return hidden.value.trim();
|
|
if (input && input.value) return input.value.trim();
|
|
return '';
|
|
}
|
|
|
|
function getMarketCodes() {
|
|
return {
|
|
symbol: getSymbol(),
|
|
market_code: (document.getElementById('market-market-code') || {}).value || '',
|
|
sina_code: (document.getElementById('market-sina-code') || {}).value || '',
|
|
};
|
|
}
|
|
|
|
function themeColors() {
|
|
var dark = document.documentElement.getAttribute('data-theme') !== 'light';
|
|
return {
|
|
bg: dark ? '#0a0c14' : '#f4f7fc',
|
|
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',
|
|
ma55: dark ? '#c084fc' : '#7c3aed',
|
|
prevClose: dark ? '#fbbf24' : '#b45309',
|
|
};
|
|
}
|
|
|
|
function isTradingSession() {
|
|
var d = new Date();
|
|
var wd = d.getDay();
|
|
if (wd === 0) return false;
|
|
if (wd === 6 && d.getHours() < 21) return false;
|
|
var t = d.getHours() * 60 + d.getMinutes();
|
|
function inRange(sh, sm, eh, em) {
|
|
return t >= sh * 60 + sm && t < eh * 60 + em;
|
|
}
|
|
if (inRange(9, 0, 11, 30)) return true;
|
|
if (inRange(13, 30, 15, 0)) return true;
|
|
if (inRange(21, 0, 24, 0)) return true;
|
|
if (inRange(0, 0, 2, 30)) return true;
|
|
return false;
|
|
}
|
|
|
|
function calcMA(period, closes) {
|
|
var result = [];
|
|
for (var i = 0; i < closes.length; i++) {
|
|
if (i < period - 1) {
|
|
result.push(null);
|
|
continue;
|
|
}
|
|
var sum = 0;
|
|
for (var j = 0; j < period; j++) sum += closes[i - j];
|
|
result.push(+(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' });
|
|
}
|
|
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 };
|
|
}
|
|
return { start: getDefaultZoomStart(), end: 100 };
|
|
}
|
|
|
|
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);
|
|
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 },
|
|
},
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return zoom;
|
|
}
|
|
|
|
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 renderChart(data, preserveZoom) {
|
|
if (!chart) 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 grids = [
|
|
{ left: 56, right: 24, top: 44, height: '50%' },
|
|
{ left: 56, right: 24, top: '66%', height: '14%' },
|
|
];
|
|
|
|
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,
|
|
});
|
|
} 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,
|
|
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 };
|
|
}
|
|
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/>');
|
|
},
|
|
};
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function periodLabel(key) {
|
|
var tabs = document.querySelectorAll('.period-tab');
|
|
for (var i = 0; i < tabs.length; i++) {
|
|
if (tabs[i].getAttribute('data-period') === key) return tabs[i].textContent;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
function hideEmptyOverlay() {
|
|
if (wrapEl) wrapEl.classList.add('has-data');
|
|
}
|
|
|
|
function showEmptyOverlay(text) {
|
|
if (emptyEl) emptyEl.textContent = text;
|
|
if (wrapEl) wrapEl.classList.remove('has-data');
|
|
}
|
|
|
|
function setLoading(on) {
|
|
var btn = document.getElementById('market-load-btn');
|
|
if (btn) {
|
|
btn.disabled = on;
|
|
btn.textContent = on ? '连接中…' : '查看';
|
|
}
|
|
if (!wrapEl) return;
|
|
if (on && !lastData) {
|
|
wrapEl.classList.add('loading');
|
|
showEmptyOverlay('请选择合约并点击「查看」');
|
|
} else {
|
|
wrapEl.classList.remove('loading');
|
|
if (lastData) hideEmptyOverlay();
|
|
}
|
|
}
|
|
|
|
function klineSourceLabel(src) {
|
|
if (src === 'ctp') return 'CTP';
|
|
if (src === 'ctp+remote') return '新浪+CTP';
|
|
if (src === 'local') return '本地缓存';
|
|
return '新浪';
|
|
}
|
|
|
|
function updateRefreshHint(disconnected) {
|
|
var el = document.getElementById('market-refresh-hint');
|
|
if (!el) return;
|
|
if (!getSymbol()) {
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
if (disconnected) {
|
|
el.textContent = 'SSE 连接中断,正在重连…';
|
|
return;
|
|
}
|
|
if (!streamActive) {
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
var src = '';
|
|
if (lastData && lastData.source) {
|
|
src = ' · ' + klineSourceLabel(lastData.source);
|
|
}
|
|
if (isTradingSession()) {
|
|
el.textContent = '交易中 · 后台刷新 · SSE 推送(约1秒)' + src;
|
|
} else {
|
|
el.textContent = 'SSE 推送 · 非交易时段低频刷新' + src;
|
|
}
|
|
}
|
|
|
|
function updatePrevCloseDisplay(val) {
|
|
var prevEl = document.getElementById('market-quote-prev');
|
|
if (!prevEl) return;
|
|
if (val != null && !isNaN(Number(val))) {
|
|
prevEl.textContent = '昨收 ' + Number(val).toFixed(2);
|
|
} else {
|
|
prevEl.textContent = '';
|
|
}
|
|
}
|
|
|
|
function applyQuote(data) {
|
|
var priceEl = document.getElementById('market-quote-price');
|
|
var nameEl = document.getElementById('market-quote-name');
|
|
if (nameEl && data.name) nameEl.textContent = data.name + ' ' + (data.symbol || '');
|
|
if (priceEl) {
|
|
priceEl.textContent = data.price != null ? Number(data.price).toFixed(2) : '—';
|
|
}
|
|
if (data.quote_source && lastData) {
|
|
updateQuoteMeta(Object.assign({}, lastData, { quote_source: data.quote_source }));
|
|
}
|
|
if (data.prev_close != null) {
|
|
lastPrevClose = data.prev_close;
|
|
updatePrevCloseDisplay(data.prev_close);
|
|
if (chartOpts.prevClose && lastData) renderChart(lastData, true);
|
|
}
|
|
}
|
|
|
|
function stopKlineStream() {
|
|
streamActive = false;
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
if (klineSource) {
|
|
klineSource.close();
|
|
klineSource = null;
|
|
}
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
if (reconnectTimer) return;
|
|
updateRefreshHint(true);
|
|
reconnectTimer = setTimeout(function () {
|
|
reconnectTimer = null;
|
|
if (getSymbol()) startKlineStream(false);
|
|
}, 3000);
|
|
}
|
|
|
|
function startKlineStream(showLoading) {
|
|
stopKlineStream();
|
|
var symbol = getSymbol();
|
|
if (!symbol) {
|
|
alert('请先选择或输入合约代码');
|
|
return;
|
|
}
|
|
if (showLoading) setLoading(true);
|
|
|
|
var codes = getMarketCodes();
|
|
var q = 'symbol=' + encodeURIComponent(symbol) +
|
|
'&period=' + encodeURIComponent(currentPeriod);
|
|
if (codes.market_code) q += '&market_code=' + encodeURIComponent(codes.market_code);
|
|
if (codes.sina_code) q += '&sina_code=' + encodeURIComponent(codes.sina_code);
|
|
|
|
klineSource = new EventSource('/api/kline/stream?' + q);
|
|
streamActive = true;
|
|
updateRefreshHint(false);
|
|
|
|
klineSource.addEventListener('kline', function (e) {
|
|
try {
|
|
var data = JSON.parse(e.data);
|
|
if (!data.bars || !data.bars.length) return;
|
|
hideEmptyOverlay();
|
|
renderChart(data, lastData !== null);
|
|
updateQuoteMeta(data);
|
|
if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close);
|
|
updateRefreshHint(false);
|
|
setLoading(false);
|
|
} catch (err) { /* ignore */ }
|
|
});
|
|
|
|
klineSource.addEventListener('quote', function (e) {
|
|
try {
|
|
applyQuote(JSON.parse(e.data));
|
|
} catch (err) { /* ignore */ }
|
|
});
|
|
|
|
klineSource.onerror = function () {
|
|
stopKlineStream();
|
|
scheduleReconnect();
|
|
};
|
|
}
|
|
|
|
function updateQuoteMeta(data) {
|
|
var meta = document.getElementById('market-quote-meta');
|
|
if (meta) {
|
|
var parts = [];
|
|
if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period));
|
|
if (data.source) parts.push('K线 ' + klineSourceLabel(data.source));
|
|
if (data.quote_source) {
|
|
parts.push('报价 ' + (data.quote_source === 'ctp' ? 'CTP' : '新浪'));
|
|
}
|
|
meta.textContent = parts.join(' · ');
|
|
}
|
|
var nameEl = document.getElementById('market-quote-name');
|
|
var hiddenName = document.getElementById('market-symbol-name');
|
|
if (nameEl && !(nameEl.textContent && nameEl.textContent.trim())) {
|
|
nameEl.textContent = (hiddenName && hiddenName.value) || data.symbol || '—';
|
|
}
|
|
}
|
|
|
|
function loadKline(showLoading) {
|
|
startKlineStream(showLoading);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
function bindPeriodTabs() {
|
|
var tabs = document.getElementById('market-period-tabs');
|
|
if (!tabs) return;
|
|
tabs.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.period-tab');
|
|
if (!btn) return;
|
|
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
|
|
btn.classList.add('active');
|
|
currentPeriod = btn.getAttribute('data-period') || '15m';
|
|
if (getSymbol()) loadKline(true);
|
|
});
|
|
}
|
|
|
|
function bindZoomButtons() {
|
|
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 (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
|
|
}
|
|
|
|
function bindChartOptions() {
|
|
var prevCb = document.getElementById('chart-opt-prev-close');
|
|
var maCb = document.getElementById('chart-opt-ma');
|
|
var gapCb = document.getElementById('chart-opt-gap-day');
|
|
if (prevCb) {
|
|
prevCb.addEventListener('change', function () {
|
|
chartOpts.prevClose = prevCb.checked;
|
|
if (lastData) renderChart(lastData, true);
|
|
});
|
|
}
|
|
if (maCb) {
|
|
maCb.addEventListener('change', function () {
|
|
chartOpts.ma = maCb.checked;
|
|
if (lastData) renderChart(lastData, true);
|
|
});
|
|
}
|
|
if (gapCb) {
|
|
gapCb.addEventListener('change', function () {
|
|
chartOpts.gapDay = gapCb.checked;
|
|
if (lastData) renderChart(lastData, false);
|
|
});
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
initChart();
|
|
bindPeriodTabs();
|
|
bindZoomButtons();
|
|
bindChartOptions();
|
|
|
|
var active = document.querySelector('.period-tab.active');
|
|
if (active) currentPeriod = active.getAttribute('data-period') || '15m';
|
|
|
|
var loadBtn = document.getElementById('market-load-btn');
|
|
if (loadBtn) loadBtn.addEventListener('click', function () { loadKline(true); });
|
|
|
|
var hidden = document.getElementById('market-symbol-hidden');
|
|
var input = document.getElementById('market-symbol-input');
|
|
if (input) {
|
|
input.addEventListener('symbol-selected', function () {
|
|
lastPrevClose = null;
|
|
updatePrevCloseDisplay(null);
|
|
loadKline(true);
|
|
});
|
|
}
|
|
if (hidden && hidden.value) {
|
|
if (input && !input.value) input.value = hidden.value;
|
|
loadKline(true);
|
|
} else {
|
|
updateRefreshHint(false);
|
|
}
|
|
|
|
window.addEventListener('beforeunload', stopKlineStream);
|
|
});
|
|
})();
|