Files
Binance_Altcoin_Monitor/web/charts.js
T
2026-05-26 09:38:23 +08:00

343 lines
9.9 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 + 成交量(Canvas 高清)· 浏览器 localStorage 缓存 · 点击全屏 */
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 };
function modalSize() {
const fs = document.fullscreenElement;
if (fs) {
return {
w: Math.max(800, window.innerWidth - 48),
h: Math.max(480, window.innerHeight - 100),
};
}
return { w: 1280, h: 720 };
}
function loadKlineFromLS(symbol) {
try {
const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol);
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, candles, source) {
try {
localStorage.setItem(
LS_KLINE_PREFIX + symbol,
JSON.stringify({ ts: Date.now(), candles, source })
);
} catch {
/* quota */
}
}
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 ? modalSize() : 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);
if (large) {
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = pad.t + (priceH * i) / 4;
ctx.beginPath();
ctx.moveTo(pad.l, y);
ctx.lineTo(w - pad.r, y);
ctx.stroke();
const price = pMax - (pRange * i) / 4;
ctx.fillStyle = COLORS.text;
ctx.font = "11px Segoe UI, system-ui, sans-serif";
ctx.textAlign = "right";
ctx.fillText(price.toPrecision(6), pad.l - 8, y + 4);
}
ctx.fillStyle = COLORS.text;
ctx.font = "12px Segoe UI, system-ui, sans-serif";
ctx.textAlign = "left";
ctx.fillText("价格", pad.l, pad.t - 4);
ctx.fillText("成交量", pad.l, volTop - 4);
}
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, large = false) {
if (!canvas) return;
const size = large ? modalSize() : MINI_SIZE;
const { ctx, w, h } = setupCanvas(canvas, size.w, 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) {
let candles = chartDataCache.get(symbol);
let source = "memory";
if (candles) return { candles, source };
const ls = loadKlineFromLS(symbol);
if (ls) {
candles = ls.candles;
source = "browser";
chartDataCache.set(symbol, candles);
return { candles, source: ls.source || source };
}
const res = await fetch(`/api/chart/${symbol}/daily?limit=300`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
candles = data.candles || [];
source = data.source || "db";
chartDataCache.set(symbol, candles);
saveKlineToLS(symbol, candles, source);
return { candles, source };
}
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);
if (!candles.length) throw new Error("无K线数据");
drawCandlestickChart(canvas, candles, { large: false });
box.dataset.loaded = "1";
const srcLabel =
source === "browser"
? "浏览器"
: source === "db"
? "本地"
: source === "db_stale"
? "本地(旧)"
: source === "cache"
? "缓存"
: "同步";
if (status) status.textContent = `${candles.length}日·${srcLabel}`;
box.title = `${symbol} 日K+量 ${candles.length}根 (${srcLabel}),点击全屏`;
} catch (e) {
if (status) status.textContent = "—";
box.title = `${symbol}: ${e.message}`;
drawEmptyChart(canvas, false);
} finally {
box.dataset.loading = "0";
}
}
let chartModalSymbol = "";
function closeChartModal() {
const modal = document.getElementById("chart-modal");
if (!modal) return;
modal.classList.add("hidden");
if (document.fullscreenElement) {
document.exitFullscreen?.().catch(() => {});
}
}
function openChartModal(symbol) {
const candles = chartDataCache.get(symbol);
if (!candles?.length) return;
chartModalSymbol = symbol;
const modal = document.getElementById("chart-modal");
modal.classList.remove("hidden");
document.getElementById("chart-modal-title").textContent =
`${symbol} · 日K + 成交量(${candles.length} 根)`;
const canvas = document.getElementById("chart-modal-canvas");
drawCandlestickChart(canvas, candles, { large: true });
const inner = modal.querySelector(".chart-modal-inner");
const req = inner.requestFullscreen || inner.webkitRequestFullscreen;
if (req) {
req.call(inner).catch(() => {});
}
}
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>
<h3 id="chart-modal-title"></h3>
<p class="chart-modal-hint">日K + 成交量 · 300根 · 点击全屏 · Esc 退出</p>
<div class="chart-modal-canvas-wrap">
<canvas id="chart-modal-canvas"></canvas>
</div>
</div>`;
document.body.appendChild(modal);
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();
});
document.addEventListener("fullscreenchange", () => {
if (!chartModalSymbol) return;
const canvas = document.getElementById("chart-modal-canvas");
const candles = chartDataCache.get(chartModalSymbol);
if (canvas && candles?.length) {
drawCandlestickChart(canvas, candles, { large: true });
}
});
}
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();