增加K线
This commit is contained in:
+271
-78
@@ -1,4 +1,15 @@
|
||||
/** 日 K + 成交量(Canvas 高清)· 浏览器 localStorage 缓存 · 点击全屏 */
|
||||
/** 多周期 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 = [];
|
||||
@@ -19,21 +30,37 @@ const COLORS = {
|
||||
};
|
||||
|
||||
const MINI_SIZE = { w: 380, h: 100 };
|
||||
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 modalSize() {
|
||||
const fs = document.fullscreenElement;
|
||||
if (fs) {
|
||||
return {
|
||||
w: Math.max(800, window.innerWidth - 48),
|
||||
h: Math.max(480, window.innerHeight - 100),
|
||||
h: Math.max(480, window.innerHeight - 160),
|
||||
};
|
||||
}
|
||||
return { w: 1280, h: 720 };
|
||||
return { w: 1280, h: 680 };
|
||||
}
|
||||
|
||||
function loadKlineFromLS(symbol) {
|
||||
function loadKlineFromLS(symbol, interval) {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol);
|
||||
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;
|
||||
@@ -43,17 +70,59 @@ function loadKlineFromLS(symbol) {
|
||||
}
|
||||
}
|
||||
|
||||
function saveKlineToLS(symbol, candles, source) {
|
||||
function saveKlineToLS(symbol, interval, candles, source) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LS_KLINE_PREFIX + symbol,
|
||||
JSON.stringify({ ts: Date.now(), candles, source })
|
||||
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;
|
||||
@@ -134,28 +203,6 @@ function drawCandlestickChart(canvas, candles, options = {}) {
|
||||
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();
|
||||
@@ -198,10 +245,9 @@ function drawCandlestickChart(canvas, candles, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function drawEmptyChart(canvas, large = false) {
|
||||
function drawEmptyChart(canvas) {
|
||||
if (!canvas) return;
|
||||
const size = large ? modalSize() : MINI_SIZE;
|
||||
const { ctx, w, h } = setupCanvas(canvas, size.w, size.h);
|
||||
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;
|
||||
@@ -209,30 +255,33 @@ function drawEmptyChart(canvas, large = false) {
|
||||
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
|
||||
}
|
||||
|
||||
async function fetchKlines(symbol) {
|
||||
let candles = chartDataCache.get(symbol);
|
||||
let source = "memory";
|
||||
if (candles) return { candles, source };
|
||||
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);
|
||||
const ls = loadKlineFromLS(symbol, interval);
|
||||
if (ls) {
|
||||
candles = ls.candles;
|
||||
source = "browser";
|
||||
chartDataCache.set(symbol, candles);
|
||||
return { candles, source: ls.source || source };
|
||||
const result = { candles: ls.candles, source: ls.source || "browser", interval };
|
||||
chartDataCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/chart/${symbol}/daily?limit=300`);
|
||||
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();
|
||||
candles = data.candles || [];
|
||||
source = data.source || "db";
|
||||
chartDataCache.set(symbol, candles);
|
||||
saveKlineToLS(symbol, candles, source);
|
||||
return { candles, source };
|
||||
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) {
|
||||
@@ -244,53 +293,178 @@ async function loadMiniChart(box) {
|
||||
if (status) status.textContent = "加载…";
|
||||
|
||||
try {
|
||||
const { candles, source } = await fetchKlines(symbol);
|
||||
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";
|
||||
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}),点击全屏`;
|
||||
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, false);
|
||||
drawEmptyChart(canvas);
|
||||
} finally {
|
||||
box.dataset.loading = "0";
|
||||
}
|
||||
}
|
||||
|
||||
let chartModalSymbol = "";
|
||||
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 } = modalSize();
|
||||
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 = "";
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function openChartModal(symbol) {
|
||||
const candles = chartDataCache.get(symbol);
|
||||
if (!candles?.length) return;
|
||||
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");
|
||||
document.getElementById("chart-modal-title").textContent =
|
||||
`${symbol} · 日K + 成交量(${candles.length} 根)`;
|
||||
const canvas = document.getElementById("chart-modal-canvas");
|
||||
drawCandlestickChart(canvas, candles, { large: true });
|
||||
|
||||
const container = document.getElementById("chart-modal-container");
|
||||
if (container) container.innerHTML = "";
|
||||
|
||||
await loadModalChart(DEFAULT_MINI_INTERVAL);
|
||||
|
||||
const inner = modal.querySelector(".chart-modal-inner");
|
||||
const req = inner.requestFullscreen || inner.webkitRequestFullscreen;
|
||||
@@ -308,13 +482,31 @@ function setupChartModal() {
|
||||
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-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">
|
||||
<canvas id="chart-modal-canvas"></canvas>
|
||||
<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();
|
||||
@@ -323,11 +515,12 @@ function setupChartModal() {
|
||||
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 });
|
||||
if (!chartModalSymbol || !lwcChart) return;
|
||||
const container = document.getElementById("chart-modal-container");
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
lwcChart.applyOptions({ width: rect.width, height: rect.height });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
<span id="push-status" class="push-status"></span>
|
||||
</footer>
|
||||
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="/static/charts.js"></script>
|
||||
<script src="/static/funding.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
+65
-3
@@ -352,9 +352,71 @@ button:hover {
|
||||
.chart-modal-inner:fullscreen .chart-modal-canvas-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-modal-inner:fullscreen .chart-lwc-container {
|
||||
flex: 1;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-modal-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.chart-interval-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.chart-interval-btn {
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chart-interval-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chart-interval-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #0d1118;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chart-lwc-container {
|
||||
width: 100%;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.chart-lwc-fallback {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chart-modal-inner:fullscreen .chart-modal-head {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-modal-inner:fullscreen .chart-modal-hint {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-table .stats-total-vol {
|
||||
@@ -468,7 +530,7 @@ button:hover {
|
||||
}
|
||||
|
||||
.chart-modal-canvas-wrap {
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0d1118;
|
||||
|
||||
Reference in New Issue
Block a user