526 lines
15 KiB
JavaScript
526 lines
15 KiB
JavaScript
/** 多周期 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;
|
||
|
||
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) {
|
||
try {
|
||
localStorage.setItem(
|
||
LS_KLINE_PREFIX + symbol + "_" + interval,
|
||
JSON.stringify({ ts: Date.now(), candles, source, interval })
|
||
);
|
||
} catch {
|
||
/* quota */
|
||
}
|
||
}
|
||
|
||
function sourceLabel(source) {
|
||
if (source === "browser") return "浏览器";
|
||
if (source === "db") return "本地";
|
||
if (source === "db_stale") return "本地(旧)";
|
||
if (source === "memory") return "缓存";
|
||
return "同步";
|
||
}
|
||
|
||
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 result = { candles: ls.candles, source: ls.source || "browser", interval };
|
||
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 result = {
|
||
candles: data.candles || [],
|
||
source: data.source || "db",
|
||
interval,
|
||
};
|
||
chartDataCache.set(key, result);
|
||
saveKlineToLS(symbol, interval, result.candles, result.source);
|
||
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() {
|
||
if (lwcResizeObserver) {
|
||
lwcResizeObserver.disconnect();
|
||
lwcResizeObserver = null;
|
||
}
|
||
if (lwcChart) {
|
||
lwcChart.remove();
|
||
lwcChart = null;
|
||
lwcCandleSeries = null;
|
||
lwcVolumeSeries = null;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
const container = document.getElementById("chart-modal-container");
|
||
if (!container) return;
|
||
|
||
if (!lwcChart) ensureLwcChart(container);
|
||
if (!lwcCandleSeries || !lwcVolumeSeries) return;
|
||
|
||
const { ohlc, vol } = candlesToLwc(candles, interval);
|
||
lwcCandleSeries.setData(ohlc);
|
||
lwcVolumeSeries.setData(vol);
|
||
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 } = await fetchKlines(chartModalSymbol, interval);
|
||
if (!candles.length) throw new Error("无K线数据");
|
||
renderLwcChart(candles, interval);
|
||
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();
|