增加K线

This commit is contained in:
dekun
2026-05-30 11:12:47 +08:00
parent 3053527504
commit 387009d9e3
+167 -8
View File
@@ -52,6 +52,14 @@ let lwcOnCrosshairMove = null;
let lwcOnChartClick = null; let lwcOnChartClick = null;
let lwcPinnedCandleTime = null; let lwcPinnedCandleTime = null;
const BINANCE_KLINE_WS = "wss://fstream.binance.com/ws";
let klineWs = null;
let klineWsSymbol = "";
let klineWsInterval = "";
let klineWsReconnectTimer = null;
let modalMetaBase = { count: 0, source: "db", interval: "1d" };
let highLowWsRaf = 0;
function cacheKey(symbol, interval) { function cacheKey(symbol, interval) {
return `${symbol}:${interval}`; return `${symbol}:${interval}`;
} }
@@ -98,6 +106,7 @@ function saveKlineToLS(symbol, interval, candles, source, priceMeta) {
} }
function sourceLabel(source) { function sourceLabel(source) {
if (source === "ws") return "实时";
if (source === "browser") return "浏览器"; if (source === "browser") return "浏览器";
if (source === "db") return "本地"; if (source === "db") return "本地";
if (source === "db_stale") return "本地(旧)"; if (source === "db_stale") return "本地(旧)";
@@ -365,6 +374,151 @@ function candlesToLwc(candles, interval) {
return { ohlc, vol }; return { ohlc, vol };
} }
function upsertModalCandleFromWs(k) {
const candle = {
time: Number(k.t),
open: Number(k.o),
high: Number(k.h),
low: Number(k.l),
close: Number(k.c),
volume: Number(k.v),
quote_volume: Number(k.q),
};
const idx = lwcModalCandles.findIndex((c) => c.time === candle.time);
if (idx >= 0) {
lwcModalCandles[idx] = candle;
} else if (
!lwcModalCandles.length ||
candle.time > lwcModalCandles[lwcModalCandles.length - 1].time
) {
lwcModalCandles.push(candle);
const max = limitForInterval(lwcModalInterval);
while (lwcModalCandles.length > max) lwcModalCandles.shift();
}
return candle;
}
function applyRealtimeCandle(candle) {
if (!lwcCandleSeries || !lwcVolumeSeries) return;
const t = toLwcTime(candle.time, lwcModalInterval);
const up = candle.close >= candle.open;
lwcCandleSeries.update({
time: t,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
});
lwcVolumeSeries.update({
time: t,
value: Number(candle.quote_volume || candle.volume || 0),
color: up ? COLORS.volUp : COLORS.volDown,
});
}
function syncChartCacheFromModal() {
const key = cacheKey(chartModalSymbol, lwcModalInterval);
const cached = chartDataCache.get(key);
if (cached) {
cached.candles = lwcModalCandles.slice();
cached.source = "ws";
}
}
function scheduleHighLowFromWs() {
if (highLowWsRaf) return;
highLowWsRaf = requestAnimationFrame(() => {
highLowWsRaf = 0;
updateHighLowForVisibleWindow();
});
}
function onWsKlineUpdate(k) {
const candle = upsertModalCandleFromWs(k);
applyRealtimeCandle(candle);
syncChartCacheFromModal();
if (lwcPinnedCandleTime == null) {
renderOhlcPanel(candle, "实时");
}
scheduleHighLowFromWs();
}
function isKlineWsLive() {
return klineWs?.readyState === WebSocket.OPEN;
}
function updateModalHint() {
const hint = document.getElementById("chart-modal-hint");
if (!hint) return;
const src = isKlineWsLive() ? "实时 WS" : sourceLabel(modalMetaBase.source);
hint.textContent = `${modalMetaBase.count} 根 · ${src} · 十字线看当前 · 点击选中 · Esc 关闭`;
}
function disconnectKlineWs() {
if (klineWsReconnectTimer) {
clearTimeout(klineWsReconnectTimer);
klineWsReconnectTimer = null;
}
if (klineWs) {
klineWs.onopen = null;
klineWs.onmessage = null;
klineWs.onclose = null;
klineWs.onerror = null;
try {
klineWs.close();
} catch {
/* ignore */
}
klineWs = null;
}
klineWsSymbol = "";
klineWsInterval = "";
}
function scheduleKlineWsReconnect() {
if (!chartModalSymbol || klineWsReconnectTimer) return;
klineWsReconnectTimer = setTimeout(() => {
klineWsReconnectTimer = null;
if (chartModalSymbol) connectKlineWs(chartModalSymbol, chartModalInterval);
}, 3000);
}
function connectKlineWs(symbol, interval) {
disconnectKlineWs();
const sym = symbol.toUpperCase();
const iv = interval.toLowerCase();
klineWsSymbol = sym;
klineWsInterval = iv;
const ws = new WebSocket(`${BINANCE_KLINE_WS}/${sym.toLowerCase()}@kline_${iv}`);
klineWs = ws;
ws.onopen = () => updateModalHint();
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.e !== "kline" || !msg.k) return;
if (msg.k.s !== sym || msg.k.i !== iv) return;
onWsKlineUpdate(msg.k);
} catch {
/* ignore malformed */
}
};
ws.onclose = () => {
if (klineWs === ws) {
klineWs = null;
updateModalHint();
if (chartModalSymbol && klineWsSymbol === sym && klineWsInterval === iv) {
scheduleKlineWsReconnect();
}
}
};
ws.onerror = () => updateModalHint();
}
function enqueueCharts(root) { function enqueueCharts(root) {
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => { root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
const symbol = box.dataset.symbol; const symbol = box.dataset.symbol;
@@ -497,9 +651,10 @@ function drawEmptyChart(canvas) {
ctx.fillText("暂无数据", w / 2 - 28, h / 2); ctx.fillText("暂无数据", w / 2 - 28, h / 2);
} }
async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) { async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL, forceRefresh = false) {
const key = cacheKey(symbol, interval); const key = cacheKey(symbol, interval);
let cached = chartDataCache.get(key); if (!forceRefresh) {
const cached = chartDataCache.get(key);
if (cached) return cached; if (cached) return cached;
const ls = loadKlineFromLS(symbol, interval); const ls = loadKlineFromLS(symbol, interval);
@@ -515,9 +670,11 @@ async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) {
chartDataCache.set(key, result); chartDataCache.set(key, result);
return result; return result;
} }
}
const limit = limitForInterval(interval); const limit = limitForInterval(interval);
const res = await fetch(`/api/chart/${symbol}?interval=${interval}&limit=${limit}`); const refreshQ = forceRefresh ? "&refresh=true" : "";
const res = await fetch(`/api/chart/${symbol}?interval=${interval}&limit=${limit}${refreshQ}`);
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText); throw new Error(err.detail || res.statusText);
@@ -561,6 +718,7 @@ async function loadMiniChart(box) {
} }
function destroyLwcChart() { function destroyLwcChart() {
disconnectKlineWs();
unbindVisibleRangeHighLow(); unbindVisibleRangeHighLow();
unbindChartInteractions(); unbindChartInteractions();
clearHighLowAnnotations(); clearHighLowAnnotations();
@@ -753,14 +911,12 @@ function updateIntervalTabs() {
} }
function updateModalMeta(candles, source, interval) { function updateModalMeta(candles, source, interval) {
modalMetaBase = { count: candles.length, source, interval };
const title = document.getElementById("chart-modal-title"); const title = document.getElementById("chart-modal-title");
const hint = document.getElementById("chart-modal-hint");
if (title) { if (title) {
title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`; title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`;
} }
if (hint) { updateModalHint();
hint.textContent = `${candles.length} 根 · ${sourceLabel(source)} · 十字线看当前 · 点击选中 · Esc 关闭`;
}
} }
async function loadModalChart(interval) { async function loadModalChart(interval) {
@@ -774,12 +930,15 @@ async function loadModalChart(interval) {
try { try {
const { candles, source, tick_size, price_precision } = await fetchKlines( const { candles, source, tick_size, price_precision } = await fetchKlines(
chartModalSymbol, chartModalSymbol,
interval interval,
true
); );
if (!candles.length) throw new Error("无K线数据"); if (!candles.length) throw new Error("无K线数据");
renderLwcChart(candles, interval, { tick_size, price_precision }); renderLwcChart(candles, interval, { tick_size, price_precision });
updateModalMeta(candles, source, interval); updateModalMeta(candles, source, interval);
connectKlineWs(chartModalSymbol, interval);
} catch (e) { } catch (e) {
disconnectKlineWs();
if (hint) hint.textContent = `加载失败: ${e.message}`; if (hint) hint.textContent = `加载失败: ${e.message}`;
destroyLwcChart(); destroyLwcChart();
if (container) { if (container) {