/** 日 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 = `
日K + 成交量 · 300根 · 滚轮可缩放页面