Files
Binance_Altcoin_Monitor/web/charts.js
T
2026-05-30 10:48:57 +08:00

679 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 多周期 K 线 · SQLite 后端 + localStorage · 弹窗大图 Lightweight Charts */
const CHART_INTERVALS = ["5m", "15m", "30m", "1h", "4h", "1d", "1w"];
const INTERVAL_LIMITS = {
"5m": 1000,
"15m": 1000,
"30m": 1000,
"1h": 1000,
"4h": 1000,
"1d": 500,
"1w": 500,
};
const chartDataCache = new Map();
const chartQueue = [];
let chartQueueRunning = false;
const CHART_FETCH_GAP_MS = 120;
const LS_KLINE_PREFIX = "ba_kline_";
const KLINE_TTL_MS = 60 * 60 * 1000;
const COLORS = {
bg: "#0d1118",
grid: "#2a3548",
up: "#0ecb81",
down: "#f6465d",
volUp: "rgba(14, 203, 129, 0.55)",
volDown: "rgba(246, 70, 93, 0.55)",
text: "#8b9cb3",
};
const MINI_SIZE = { w: 380, h: 100 };
/** 弹窗图表最小尺寸;实际按视口放大(带鱼屏可接近全宽) */
const MODAL_CHART_MIN = { w: 1280, h: 560 };
const DEFAULT_MINI_INTERVAL = "1d";
let chartModalSymbol = "";
let chartModalInterval = "1d";
let lwcChart = null;
let lwcCandleSeries = null;
let lwcVolumeSeries = null;
let lwcResizeObserver = null;
let lwcPriceLines = [];
const symbolPriceMeta = new Map();
function cacheKey(symbol, interval) {
return `${symbol}:${interval}`;
}
function limitForInterval(interval) {
return INTERVAL_LIMITS[interval] || 500;
}
function modalChartSize() {
const padX = 32;
const chromeY = 150;
const w = Math.max(MODAL_CHART_MIN.w, window.innerWidth - padX * 2);
const h = Math.max(MODAL_CHART_MIN.h, window.innerHeight - chromeY);
return { w, h };
}
function loadKlineFromLS(symbol, interval) {
try {
const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol + "_" + interval);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj?.candles?.length || Date.now() - (obj.ts || 0) > KLINE_TTL_MS) return null;
return obj;
} catch {
return null;
}
}
function saveKlineToLS(symbol, interval, candles, source, priceMeta) {
try {
localStorage.setItem(
LS_KLINE_PREFIX + symbol + "_" + interval,
JSON.stringify({
ts: Date.now(),
candles,
source,
interval,
tick_size: priceMeta?.tick_size,
price_precision: priceMeta?.price_precision,
})
);
} catch {
/* quota */
}
}
function sourceLabel(source) {
if (source === "browser") return "浏览器";
if (source === "db") return "本地";
if (source === "db_stale") return "本地(旧)";
if (source === "memory") return "缓存";
return "同步";
}
function parseMinMove(tickSize) {
const n = Number(tickSize);
return Number.isFinite(n) && n > 0 ? n : 0.01;
}
function formatPrice(price, precision) {
return Number(price).toFixed(precision);
}
function rememberPriceMeta(symbol, meta) {
if (!meta?.tick_size) return null;
const priceMeta = {
tick_size: meta.tick_size,
price_precision: Number(meta.price_precision ?? 2),
};
symbolPriceMeta.set(symbol, priceMeta);
return priceMeta;
}
function getPriceMeta(symbol, fallback) {
return (
symbolPriceMeta.get(symbol) ||
(fallback?.tick_size ? rememberPriceMeta(symbol, fallback) : null) || {
tick_size: "0.01",
price_precision: 2,
}
);
}
function findCandleExtremes(candles, interval) {
let maxHigh = -Infinity;
let minLow = Infinity;
let highTime = null;
let lowTime = null;
for (const c of candles) {
if (c.high > maxHigh) {
maxHigh = c.high;
highTime = toLwcTime(c.time, interval);
}
if (c.low < minLow) {
minLow = c.low;
lowTime = toLwcTime(c.time, interval);
}
}
return { maxHigh, minLow, highTime, lowTime };
}
function toLwcTime(ms, interval) {
if (interval === "1d" || interval === "1w") {
const d = new Date(ms);
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate(),
};
}
return Math.floor(ms / 1000);
}
function candlesToLwc(candles, interval) {
const ohlc = [];
const vol = [];
for (const c of candles) {
const t = toLwcTime(c.time, interval);
const up = c.close >= c.open;
ohlc.push({
time: t,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
});
vol.push({
time: t,
value: Number(c.quote_volume || c.volume || 0),
color: up ? COLORS.volUp : COLORS.volDown,
});
}
return { ohlc, vol };
}
function enqueueCharts(root) {
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
const symbol = box.dataset.symbol;
if (!symbol || box.dataset.loaded === "1" || box.dataset.loading === "1") return;
chartQueue.push(box);
});
runChartQueue();
}
async function runChartQueue() {
if (chartQueueRunning) return;
chartQueueRunning = true;
while (chartQueue.length) {
const box = chartQueue.shift();
if (!box || !box.isConnected) continue;
await loadMiniChart(box);
await sleep(CHART_FETCH_GAP_MS);
}
chartQueueRunning = false;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function volOf(c) {
return Number(c.quote_volume || c.volume || 0);
}
function setupCanvas(canvas, displayW, displayH) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.style.width = `${displayW}px`;
canvas.style.height = `${displayH}px`;
const pw = Math.floor(displayW * dpr);
const ph = Math.floor(displayH * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, w: displayW, h: displayH };
}
function drawCandlestickChart(canvas, candles, options = {}) {
if (!canvas || !candles.length) return;
const large = options.large === true;
const size = large ? modalChartSize() : MINI_SIZE;
const volRatio = large ? 0.22 : 0.32;
const pad = large
? { t: 16, r: 16, b: 28, l: 56 }
: { t: 6, r: 6, b: 14, l: 6 };
const { ctx, w, h } = setupCanvas(canvas, size.w, size.h);
const priceH = (h - pad.t - pad.b) * (1 - volRatio);
const volH = (h - pad.t - pad.b) * volRatio;
const volTop = pad.t + priceH + (large ? 8 : 4);
const plotW = w - pad.l - pad.r;
const n = candles.length;
const step = plotW / n;
let pMin = Infinity;
let pMax = -Infinity;
let vMax = 0;
for (const c of candles) {
pMin = Math.min(pMin, c.low);
pMax = Math.max(pMax, c.high);
vMax = Math.max(vMax, volOf(c));
}
const pRange = pMax - pMin || 1;
vMax = vMax || 1;
const yPrice = (p) => pad.t + priceH * (1 - (p - pMin) / pRange);
const yVol = (v) => volTop + volH * (1 - v / vMax);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.l, volTop - 2);
ctx.lineTo(w - pad.r, volTop - 2);
ctx.stroke();
const bodyW = Math.max(large ? 2 : 1, step * (large ? 0.72 : 0.68));
for (let i = 0; i < n; i++) {
const c = candles[i];
const up = c.close >= c.open;
const x = pad.l + i * step + step / 2;
const color = up ? COLORS.up : COLORS.down;
const volColor = up ? COLORS.volUp : COLORS.volDown;
const yHigh = yPrice(c.high);
const yLow = yPrice(c.low);
const yOpen = yPrice(c.open);
const yClose = yPrice(c.close);
ctx.strokeStyle = color;
ctx.lineWidth = large ? 1.5 : 1;
ctx.beginPath();
ctx.moveTo(x, yHigh);
ctx.lineTo(x, yLow);
ctx.stroke();
const top = Math.min(yOpen, yClose);
const bodyHeight = Math.max(large ? 2 : 1, Math.abs(yClose - yOpen));
ctx.fillStyle = color;
ctx.fillRect(x - bodyW / 2, top, bodyW, bodyHeight);
const v = volOf(c);
const barH = volH * (v / vMax);
if (barH > 0.5) {
ctx.fillStyle = volColor;
ctx.fillRect(x - bodyW / 2, yVol(v), bodyW, barH);
}
}
}
function drawEmptyChart(canvas) {
if (!canvas) return;
const { ctx, w, h } = setupCanvas(canvas, MINI_SIZE.w, MINI_SIZE.h);
ctx.fillStyle = "#1a2332";
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = COLORS.text;
ctx.font = "13px sans-serif";
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
}
async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) {
const key = cacheKey(symbol, interval);
let 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 limit = limitForInterval(interval);
const res = await fetch(`/api/chart/${symbol}?interval=${interval}&limit=${limit}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
const priceMeta = rememberPriceMeta(symbol, data);
const result = {
candles: data.candles || [],
source: data.source || "db",
interval,
tick_size: priceMeta.tick_size,
price_precision: priceMeta.price_precision,
};
chartDataCache.set(key, result);
saveKlineToLS(symbol, interval, result.candles, result.source, priceMeta);
return result;
}
async function loadMiniChart(box) {
const symbol = box.dataset.symbol;
if (!symbol) return;
box.dataset.loading = "1";
const canvas = box.querySelector("canvas");
const status = box.querySelector(".chart-status");
if (status) status.textContent = "加载…";
try {
const { candles, source } = await fetchKlines(symbol, DEFAULT_MINI_INTERVAL);
if (!candles.length) throw new Error("无K线数据");
drawCandlestickChart(canvas, candles, { large: false });
box.dataset.loaded = "1";
if (status) status.textContent = `${candles.length}日·${sourceLabel(source)}`;
box.title = `${symbol} 日K ${candles.length}根 (${sourceLabel(source)}),点击查看大图`;
} catch (e) {
if (status) status.textContent = "—";
box.title = `${symbol}: ${e.message}`;
drawEmptyChart(canvas);
} finally {
box.dataset.loading = "0";
}
}
function destroyLwcChart() {
clearHighLowAnnotations();
if (lwcResizeObserver) {
lwcResizeObserver.disconnect();
lwcResizeObserver = null;
}
if (lwcChart) {
lwcChart.remove();
lwcChart = null;
lwcCandleSeries = null;
lwcVolumeSeries = null;
}
}
function clearHighLowAnnotations() {
if (lwcCandleSeries) {
lwcPriceLines.forEach((line) => {
try {
lwcCandleSeries.removePriceLine(line);
} catch {
/* already removed */
}
});
lwcCandleSeries.setMarkers([]);
}
lwcPriceLines = [];
}
function applySeriesPriceFormat(priceMeta) {
if (!lwcCandleSeries) return;
const precision = priceMeta.price_precision;
const minMove = parseMinMove(priceMeta.tick_size);
lwcCandleSeries.applyOptions({
priceFormat: {
type: "price",
precision,
minMove,
},
});
}
function applyHighLowAnnotations(candles, interval, priceMeta) {
if (!lwcCandleSeries || !candles.length) return;
clearHighLowAnnotations();
const { maxHigh, minLow, highTime, lowTime } = findCandleExtremes(candles, interval);
if (!Number.isFinite(maxHigh) || !Number.isFinite(minLow)) return;
const precision = priceMeta.price_precision;
const highText = formatPrice(maxHigh, precision);
const lowText = formatPrice(minLow, precision);
lwcPriceLines.push(
lwcCandleSeries.createPriceLine({
price: maxHigh,
color: COLORS.up,
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dotted,
axisLabelVisible: true,
title: `最高 ${highText}`,
}),
lwcCandleSeries.createPriceLine({
price: minLow,
color: COLORS.down,
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dotted,
axisLabelVisible: true,
title: `最低 ${lowText}`,
})
);
const markers = [];
if (highTime != null) {
markers.push({
time: highTime,
position: "aboveBar",
color: COLORS.up,
shape: "circle",
text: `${highText}`,
});
}
if (lowTime != null) {
markers.push({
time: lowTime,
position: "belowBar",
color: COLORS.down,
shape: "circle",
text: `${lowText}`,
});
}
lwcCandleSeries.setMarkers(markers);
}
function ensureLwcChart(container) {
if (typeof LightweightCharts === "undefined") {
container.innerHTML = '<p class="chart-lwc-fallback">图表库加载失败</p>';
return null;
}
destroyLwcChart();
const { w, h } = modalChartSize();
container.style.width = `${w}px`;
container.style.height = `${h}px`;
lwcChart = LightweightCharts.createChart(container, {
width: w,
height: h,
layout: {
background: { color: COLORS.bg },
textColor: COLORS.text,
},
grid: {
vertLines: { color: COLORS.grid },
horzLines: { color: COLORS.grid },
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
rightPriceScale: { borderColor: COLORS.grid },
timeScale: {
borderColor: COLORS.grid,
timeVisible: true,
secondsVisible: false,
},
});
lwcCandleSeries = lwcChart.addCandlestickSeries({
upColor: COLORS.up,
downColor: COLORS.down,
borderUpColor: COLORS.up,
borderDownColor: COLORS.down,
wickUpColor: COLORS.up,
wickDownColor: COLORS.down,
});
lwcVolumeSeries = lwcChart.addHistogramSeries({
priceFormat: { type: "volume" },
priceScaleId: "",
});
lwcVolumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
});
lwcResizeObserver = new ResizeObserver(() => {
if (!lwcChart || !container.isConnected) return;
const rect = container.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
lwcChart.applyOptions({ width: rect.width, height: rect.height });
}
});
lwcResizeObserver.observe(container);
return lwcChart;
}
function renderLwcChart(candles, interval, priceMeta) {
const container = document.getElementById("chart-modal-container");
if (!container) return;
if (!lwcChart) ensureLwcChart(container);
if (!lwcCandleSeries || !lwcVolumeSeries) return;
const meta = getPriceMeta(chartModalSymbol, priceMeta);
applySeriesPriceFormat(meta);
const { ohlc, vol } = candlesToLwc(candles, interval);
lwcCandleSeries.setData(ohlc);
lwcVolumeSeries.setData(vol);
applyHighLowAnnotations(candles, interval, meta);
lwcChart.timeScale().fitContent();
}
function updateIntervalTabs() {
document.querySelectorAll(".chart-interval-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.interval === chartModalInterval);
});
}
function updateModalMeta(candles, 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 或点击遮罩关闭`;
}
}
async function loadModalChart(interval) {
chartModalInterval = interval;
updateIntervalTabs();
const container = document.getElementById("chart-modal-container");
const hint = document.getElementById("chart-modal-hint");
if (hint) hint.textContent = "加载中…";
try {
const { candles, source, tick_size, price_precision } = await fetchKlines(
chartModalSymbol,
interval
);
if (!candles.length) throw new Error("无K线数据");
renderLwcChart(candles, interval, { tick_size, price_precision });
updateModalMeta(candles, source, interval);
} catch (e) {
if (hint) hint.textContent = `加载失败: ${e.message}`;
destroyLwcChart();
if (container) {
container.innerHTML = `<p class="chart-lwc-fallback">${e.message}</p>`;
}
}
}
function closeChartModal() {
const modal = document.getElementById("chart-modal");
if (!modal) return;
modal.classList.add("hidden");
destroyLwcChart();
chartModalSymbol = "";
}
async function openChartModal(symbol) {
const key = cacheKey(symbol, DEFAULT_MINI_INTERVAL);
const cached = chartDataCache.get(key);
if (!cached?.candles?.length) {
try {
await fetchKlines(symbol, DEFAULT_MINI_INTERVAL);
} catch {
return;
}
}
chartModalSymbol = symbol;
chartModalInterval = DEFAULT_MINI_INTERVAL;
const modal = document.getElementById("chart-modal");
modal.classList.remove("hidden");
const container = document.getElementById("chart-modal-container");
if (container) container.innerHTML = "";
await loadModalChart(DEFAULT_MINI_INTERVAL);
}
function setupChartModal() {
let modal = document.getElementById("chart-modal");
if (!modal) {
modal = document.createElement("div");
modal.id = "chart-modal";
modal.className = "chart-modal hidden";
modal.innerHTML = `
<div class="chart-modal-inner">
<button type="button" class="chart-modal-close" aria-label="关闭">×</button>
<div class="chart-modal-head">
<h3 id="chart-modal-title"></h3>
<div class="chart-interval-tabs" id="chart-interval-tabs"></div>
</div>
<p class="chart-modal-hint" id="chart-modal-hint"></p>
<div class="chart-modal-canvas-wrap">
<div id="chart-modal-container" class="chart-lwc-container"></div>
</div>
</div>`;
document.body.appendChild(modal);
const tabs = modal.querySelector("#chart-interval-tabs");
CHART_INTERVALS.forEach((iv) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "chart-interval-btn";
btn.dataset.interval = iv;
btn.textContent = iv;
btn.addEventListener("click", () => {
if (iv === chartModalInterval || !chartModalSymbol) return;
loadModalChart(iv);
});
tabs.appendChild(btn);
});
modal.querySelector(".chart-modal-close").onclick = closeChartModal;
modal.addEventListener("click", (e) => {
if (e.target === modal) closeChartModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeChartModal();
});
window.addEventListener("resize", () => {
if (!chartModalSymbol || !lwcChart) return;
const container = document.getElementById("chart-modal-container");
if (!container?.isConnected) return;
const { w, h } = modalChartSize();
container.style.width = `${w}px`;
container.style.height = `${h}px`;
lwcChart.applyOptions({ width: w, height: h });
});
}
document.body.addEventListener("click", (e) => {
const box = e.target.closest(".mini-chart[data-symbol]");
if (!box || box.dataset.loaded !== "1") return;
openChartModal(box.dataset.symbol);
});
}
setupChartModal();