增加k线图
This commit is contained in:
+12
-5
@@ -88,7 +88,7 @@ function renderTable(tableId, tbody) {
|
||||
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
||||
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="loading">暂无数据</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading">暂无数据</td></tr>';
|
||||
updateSortHeaders(tableId);
|
||||
return;
|
||||
}
|
||||
@@ -104,7 +104,13 @@ function renderTable(tableId, tbody) {
|
||||
: idx + 1;
|
||||
return `<tr class="${highlight}">
|
||||
<td class="rank">${displayRank}</td>
|
||||
<td><strong>${row.symbol}</strong></td>
|
||||
<td class="symbol-cell"><strong>${row.symbol}</strong></td>
|
||||
<td class="chart-cell">
|
||||
<div class="mini-chart" data-symbol="${row.symbol}">
|
||||
<canvas width="300" height="64"></canvas>
|
||||
<span class="chart-status"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td data-value="${row.quote_volume ?? 0}">${row.quote_volume_fmt || row.quote_volume}</td>
|
||||
<td class="${pctClass(pct)}" data-value="${pct}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</td>
|
||||
<td data-value="${tagText(row)}">${renderTags(row)}</td>
|
||||
@@ -113,6 +119,7 @@ function renderTable(tableId, tbody) {
|
||||
.join("");
|
||||
|
||||
updateSortHeaders(tableId);
|
||||
enqueueCharts(tbody);
|
||||
}
|
||||
|
||||
function setTableData(tableId, data) {
|
||||
@@ -206,7 +213,7 @@ document.querySelectorAll("[data-reset]").forEach((btn) => {
|
||||
|
||||
async function loadYesterday() {
|
||||
const body = document.getElementById("yesterday-body");
|
||||
body.innerHTML = '<tr><td colspan="5" class="loading">加载中…</td></tr>';
|
||||
body.innerHTML = '<tr><td colspan="6" class="loading">加载中…</td></tr>';
|
||||
try {
|
||||
const res = await fetch("/api/yesterday/top30");
|
||||
const data = await res.json();
|
||||
@@ -218,7 +225,7 @@ async function loadYesterday() {
|
||||
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
|
||||
setTableData("yesterday", data);
|
||||
} catch (e) {
|
||||
body.innerHTML = `<tr><td colspan="5" class="error">加载失败: ${e.message}</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="6" class="error">加载失败: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +243,7 @@ async function loadToday() {
|
||||
setTableData("today", data);
|
||||
document.getElementById("status").textContent = "今日数据已刷新";
|
||||
} catch (e) {
|
||||
body.innerHTML = `<tr><td colspan="5" class="error">加载失败: ${e.message}</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="6" class="error">加载失败: ${e.message}</td></tr>`;
|
||||
document.getElementById("status").textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
/** 迷你日 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();
|
||||
+4
-1
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
<header>
|
||||
<h1>币安 U本位合约 · 成交额排名</h1>
|
||||
<p class="subtitle">北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5% · 点击表头可排序</p>
|
||||
<p class="subtitle">北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5% · 合约右侧 300 日K · 点击图表放大</p>
|
||||
</header>
|
||||
|
||||
<section class="panel" id="panel-yesterday">
|
||||
@@ -28,6 +28,7 @@
|
||||
<tr>
|
||||
<th class="sortable" data-sort="rank">排名</th>
|
||||
<th class="sortable" data-sort="symbol">合约</th>
|
||||
<th class="chart-col">日线图</th>
|
||||
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
||||
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
||||
<th class="sortable" data-sort="tags">标记</th>
|
||||
@@ -54,6 +55,7 @@
|
||||
<tr>
|
||||
<th class="sortable" data-sort="rank">排名</th>
|
||||
<th class="sortable" data-sort="symbol">合约</th>
|
||||
<th class="chart-col">日线图</th>
|
||||
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
||||
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
||||
<th class="sortable" data-sort="tags">标记</th>
|
||||
@@ -69,6 +71,7 @@
|
||||
<span id="status"></span>
|
||||
</footer>
|
||||
|
||||
<script src="/static/charts.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+87
-1
@@ -22,7 +22,7 @@ body {
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
padding: 1.5rem;
|
||||
max-width: 1100px;
|
||||
max-width: 1280px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@@ -218,3 +218,89 @@ button:hover {
|
||||
.error {
|
||||
color: var(--down);
|
||||
}
|
||||
|
||||
.symbol-cell {
|
||||
white-space: nowrap;
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.chart-col {
|
||||
min-width: 320px;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.chart-cell {
|
||||
padding: 0.35rem 0.5rem !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mini-chart {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
height: 64px;
|
||||
cursor: zoom-in;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: #121820;
|
||||
}
|
||||
|
||||
.mini-chart canvas {
|
||||
display: block;
|
||||
width: 300px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.chart-status {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 2px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chart-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.chart-modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-modal-inner {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.chart-modal-inner h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.chart-modal-close {
|
||||
float: right;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#chart-modal-canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
background: #121820;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user