diff --git a/web/charts.js b/web/charts.js index 7070c56..936d38a 100644 --- a/web/charts.js +++ b/web/charts.js @@ -52,6 +52,14 @@ let lwcOnCrosshairMove = null; let lwcOnChartClick = 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) { return `${symbol}:${interval}`; } @@ -98,6 +106,7 @@ function saveKlineToLS(symbol, interval, candles, source, priceMeta) { } function sourceLabel(source) { + if (source === "ws") return "实时"; if (source === "browser") return "浏览器"; if (source === "db") return "本地"; if (source === "db_stale") return "本地(旧)"; @@ -365,6 +374,151 @@ function candlesToLwc(candles, interval) { 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) { root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => { const symbol = box.dataset.symbol; @@ -497,27 +651,30 @@ function drawEmptyChart(canvas) { 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); - let cached = chartDataCache.get(key); - if (cached) return cached; + if (!forceRefresh) { + const cached = chartDataCache.get(key); + if (cached) return cached; - const ls = loadKlineFromLS(symbol, interval); - if (ls) { - const priceMeta = rememberPriceMeta(symbol, ls); - const result = { - candles: ls.candles, - source: ls.source || "browser", - interval, - tick_size: priceMeta?.tick_size, - price_precision: priceMeta?.price_precision, - }; - chartDataCache.set(key, result); - return result; + const ls = loadKlineFromLS(symbol, interval); + if (ls) { + const priceMeta = rememberPriceMeta(symbol, ls); + const result = { + candles: ls.candles, + source: ls.source || "browser", + interval, + tick_size: priceMeta?.tick_size, + price_precision: priceMeta?.price_precision, + }; + chartDataCache.set(key, result); + return result; + } } 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) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || res.statusText); @@ -561,6 +718,7 @@ async function loadMiniChart(box) { } function destroyLwcChart() { + disconnectKlineWs(); unbindVisibleRangeHighLow(); unbindChartInteractions(); clearHighLowAnnotations(); @@ -753,14 +911,12 @@ function updateIntervalTabs() { } function updateModalMeta(candles, source, interval) { + modalMetaBase = { count: candles.length, source, interval }; const title = document.getElementById("chart-modal-title"); - const hint = document.getElementById("chart-modal-hint"); if (title) { title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`; } - if (hint) { - hint.textContent = `${candles.length} 根 · ${sourceLabel(source)} · 十字线看当前 · 点击选中 · Esc 关闭`; - } + updateModalHint(); } async function loadModalChart(interval) { @@ -774,12 +930,15 @@ async function loadModalChart(interval) { try { const { candles, source, tick_size, price_precision } = await fetchKlines( chartModalSymbol, - interval + interval, + true ); if (!candles.length) throw new Error("无K线数据"); renderLwcChart(candles, interval, { tick_size, price_precision }); updateModalMeta(candles, source, interval); + connectKlineWs(chartModalSymbol, interval); } catch (e) { + disconnectKlineWs(); if (hint) hint.textContent = `加载失败: ${e.message}`; destroyLwcChart(); if (container) {