Files
Binance_Altcoin_Monitor/web/charts.js
T
2026-05-22 13:53:01 +08:00

255 lines
7.8 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 高清) */
const chartDataCache = new Map();
const chartQueue = [];
let chartQueueRunning = false;
const CHART_FETCH_GAP_MS = 120;
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_SIZE = { w: 1280, h: 720 };
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 ? MODAL_SIZE : 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 ? MODAL_SIZE : 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 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 {
let candles = chartDataCache.get(symbol);
let source = "cache";
if (!candles) {
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);
}
if (!candles.length) throw new Error("无K线数据");
drawCandlestickChart(canvas, candles, { large: false });
box.dataset.loaded = "1";
const srcLabel =
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";
}
}
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根 · 滚轮可缩放页面</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 = () =>
modal.classList.add("hidden");
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.add("hidden");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") modal.classList.add("hidden");
});
}
document.body.addEventListener("click", (e) => {
const box = e.target.closest(".mini-chart[data-symbol]");
if (!box || box.dataset.loaded !== "1") return;
const symbol = box.dataset.symbol;
const candles = chartDataCache.get(symbol);
if (!candles) return;
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 });
});
}
setupChartModal();