172 lines
5.1 KiB
JavaScript
172 lines
5.1 KiB
JavaScript
/** 迷你日 K 线图(Canvas) + 限速队列 */
|
||
|
||
const chartDataCache = new Map();
|
||
const chartQueue = [];
|
||
let chartQueueRunning = false;
|
||
const CHART_FETCH_GAP_MS = 120;
|
||
|
||
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));
|
||
}
|
||
|
||
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);
|
||
box.dataset.loaded = "1";
|
||
const srcLabel =
|
||
source === "db" ? "本地" : source === "db_stale" ? "本地(旧)" : source === "cache" ? "缓存" : "同步";
|
||
if (status) status.textContent = `${candles.length}日·${srcLabel}`;
|
||
box.title = `${symbol} 最近${candles.length}根日K (${srcLabel})`;
|
||
} catch (e) {
|
||
if (status) status.textContent = "—";
|
||
box.title = `${symbol}: ${e.message}`;
|
||
drawEmptyChart(canvas);
|
||
} finally {
|
||
box.dataset.loading = "0";
|
||
}
|
||
}
|
||
|
||
function drawEmptyChart(canvas) {
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext("2d");
|
||
const w = canvas.width;
|
||
const h = canvas.height;
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = "#3a4558";
|
||
ctx.fillRect(0, 0, w, h);
|
||
ctx.fillStyle = "#8b9cb3";
|
||
ctx.font = "11px sans-serif";
|
||
ctx.fillText("暂无", 8, h / 2 + 4);
|
||
}
|
||
|
||
function drawCandlestickChart(canvas, candles) {
|
||
if (!canvas || !candles.length) return;
|
||
const ctx = canvas.getContext("2d");
|
||
const w = canvas.width;
|
||
const h = canvas.height;
|
||
const pad = { t: 4, r: 4, b: 4, l: 4 };
|
||
const plotW = w - pad.l - pad.r;
|
||
const plotH = h - pad.t - pad.b;
|
||
|
||
let min = Infinity;
|
||
let max = -Infinity;
|
||
for (const c of candles) {
|
||
min = Math.min(min, c.low);
|
||
max = Math.max(max, c.high);
|
||
}
|
||
const range = max - min || 1;
|
||
const n = candles.length;
|
||
const step = plotW / n;
|
||
const bodyW = Math.max(1, step * 0.65);
|
||
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = "#121820";
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
const yOf = (price) => pad.t + plotH * (1 - (price - min) / range);
|
||
|
||
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 yHigh = yOf(c.high);
|
||
const yLow = yOf(c.low);
|
||
const yOpen = yOf(c.open);
|
||
const yClose = yOf(c.close);
|
||
const color = up ? "#0ecb81" : "#f6465d";
|
||
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, yHigh);
|
||
ctx.lineTo(x, yLow);
|
||
ctx.stroke();
|
||
|
||
const top = Math.min(yOpen, yClose);
|
||
const bodyH = Math.max(1, Math.abs(yClose - yOpen));
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(x - bodyW / 2, top, bodyW, bodyH);
|
||
}
|
||
}
|
||
|
||
/** 点击放大 */
|
||
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>
|
||
<canvas id="chart-modal-canvas" width="900" height="360"></canvas>
|
||
</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.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}根`;
|
||
drawCandlestickChart(
|
||
document.getElementById("chart-modal-canvas"),
|
||
candles
|
||
);
|
||
});
|
||
}
|
||
|
||
setupChartModal();
|