Add Chinese side label and click-to-zoom review charts.

Crop thumbnails around entry/exit so short trades are visible; lightbox uses a tighter window.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-21 20:31:22 +08:00
parent 656038a74d
commit 9f6651da2f
4 changed files with 221 additions and 28 deletions
+69 -2
View File
@@ -130,8 +130,26 @@ th { color: var(--muted); font-weight: 600; }
padding: 10px;
min-height: 240px;
}
.chart-box h4 { margin: 0 0 8px; font-size: 13px; color: var(--muted); }
.chart-canvas { width: 100%; height: 240px; display: block; }
.chart-box h4 {
margin: 0 0 8px;
font-size: 13px;
color: var(--muted);
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.chart-zoom-hint {
margin-left: auto;
font-size: 11px;
font-weight: 400;
}
.chart-canvas {
width: 100%;
height: 240px;
display: block;
cursor: zoom-in;
}
.empty-chart {
height: 240px;
display: flex;
@@ -140,3 +158,52 @@ th { color: var(--muted); font-weight: 600; }
color: var(--muted);
font-size: 13px;
}
.chart-lightbox {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.chart-lightbox[hidden] { display: none !important; }
.chart-lightbox-panel {
width: min(1100px, 96vw);
background: #12121c;
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px 16px 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.chart-lightbox-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.chart-lightbox-title {
font-size: 15px;
font-weight: 700;
}
.chart-lightbox-close {
border: 0;
background: transparent;
color: var(--muted);
font-size: 28px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.chart-lightbox-close:hover { color: #fff; }
.chart-lightbox-hint { margin: 0 0 10px; font-size: 12px; }
.chart-lightbox-canvas {
width: 100%;
height: min(62vh, 520px);
display: block;
border-radius: 10px;
background: #0e0e18;
}
+138 -22
View File
@@ -1,6 +1,7 @@
/** 复盘四周期:按「包含成交时刻」的 K 线落点,避免偏到下一根 */
/** 复盘四周期:进出场落在包含时刻的 K 线;点击可放大 */
(function () {
var TF_MAX = { M1: 500, M5: 200, M15: 100, H1: 50 };
var TF_ZOOM = { M1: 90, M5: 48, M15: 32, H1: 20 };
var TF_SEC = { M1: 60, M5: 300, M15: 900, H1: 3600 };
function num(v) {
@@ -9,7 +10,7 @@
return Number.isFinite(n) ? n : null;
}
/** 找到覆盖 ts 的那根 K 线:barOpen <= ts < barOpen+period(最后一根含右边界) */
/** 找到覆盖 ts 的那根 K 线:barOpen <= ts < barOpen+period */
function barIndexAt(bars, ts, tf) {
if (ts == null || !bars.length) return -1;
var period = TF_SEC[tf] || 60;
@@ -23,15 +24,19 @@
return i;
}
}
// 落在首根之前 / 末根之后:夹到边界
if (t < Number(bars[0].ts)) return 0;
return bars.length - 1;
}
function cropBars(bars, markers, tf) {
/** 始终以成交为中心裁剪到 maxN,避免短单淹没在全天 K 线里 */
function cropBars(bars, markers, tf, maxN) {
if (!bars.length) return bars;
var maxN = TF_MAX[tf] || 100;
if (bars.length <= maxN) return bars;
maxN = maxN || TF_MAX[tf] || 100;
markers = markers || {};
if (markers.openTs == null) {
return bars.length > maxN ? bars.slice(-maxN) : bars;
}
var a = barIndexAt(bars, markers.openTs, tf);
var b =
@@ -51,7 +56,6 @@
function fmtBjt(ts) {
if (ts == null) return "";
var d = new Date(Number(ts) * 1000);
// 用 UTC+8 显示,与复盘页北京时间一致
var utc = d.getTime() + d.getTimezoneOffset() * 60000;
var bjt = new Date(utc + 8 * 3600000);
var hh = String(bjt.getHours()).padStart(2, "0");
@@ -59,14 +63,23 @@
return hh + ":" + mm;
}
function drawCandleChart(canvas, bars, markers, tf) {
function sideLabel(side) {
var s = (side || "").toLowerCase();
if (s === "buy") return "买入(多)";
if (s === "sell") return "卖出(空)";
return side || "";
}
function drawCandleChart(canvas, bars, markers, tf, opts) {
if (!canvas || !bars || !bars.length) return;
bars = cropBars(bars, markers || {}, tf);
opts = opts || {};
var maxN = opts.maxN || TF_MAX[tf] || 100;
bars = cropBars(bars, markers || {}, tf, maxN);
markers = markers || {};
var dpr = window.devicePixelRatio || 1;
var cssW = canvas.clientWidth || 400;
var cssH = canvas.clientHeight || 220;
var cssW = canvas.clientWidth || opts.width || 400;
var cssH = canvas.clientHeight || opts.height || 220;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
var ctx = canvas.getContext("2d");
@@ -75,7 +88,7 @@
var padL = 4;
var padR = 4;
var padT = 4;
var padB = 16; // 底部时间轴
var padB = 16;
var min = Infinity;
var max = -Infinity;
@@ -112,7 +125,7 @@
return padL + i * slot + slot * 0.5;
};
var bodyW = Math.max(1, Math.min(6, slot * 0.6));
var bodyW = Math.max(1, Math.min(opts.zoom ? 10 : 6, slot * 0.6));
bars.forEach(function (b, i) {
var x = xOf(i);
var up = b.close >= b.open;
@@ -130,7 +143,6 @@
ctx.fillRect(x - bodyW / 2, top, bodyW, bh);
});
// 底部左右时间(北京时间),方便对照成交时刻
ctx.fillStyle = "#888899";
ctx.font = "10px Microsoft YaHei UI, sans-serif";
ctx.textBaseline = "top";
@@ -154,7 +166,7 @@
var x = xOf(i);
var y = yOf(price);
ctx.beginPath();
ctx.arc(x, y, 4.5, 0, Math.PI * 2);
ctx.arc(x, y, opts.zoom ? 6 : 4.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 1.5;
@@ -176,7 +188,7 @@
y = pe.y - 8;
}
ctx.beginPath();
ctx.arc(x, y, 4.5, 0, Math.PI * 2);
ctx.arc(x, y, opts.zoom ? 6 : 4.5, 0, Math.PI * 2);
ctx.fillStyle = exitColor;
ctx.fill();
ctx.lineWidth = 1.5;
@@ -202,15 +214,18 @@
ctx.fillText("出", px.x, px.y - 6);
}
var meta = canvas.parentElement && canvas.parentElement.querySelector(".chart-meta");
var meta =
canvas.parentElement && canvas.parentElement.querySelector(".chart-meta");
if (meta) {
var sideBit = markers.side ? " · " + sideLabel(markers.side) : "";
meta.textContent =
bars.length +
" 根 · " +
fmtBjt(bars[0].ts) +
"–" +
fmtBjt(bars[n - 1].ts) +
" 北京时间";
" 北京时间" +
sideBit;
}
}
@@ -224,18 +239,119 @@
};
}
function paintCanvas(el, opts) {
var bars = JSON.parse(el.dataset.bars || "[]");
var tf = el.dataset.tf || "M1";
drawCandleChart(el, bars, readMarkers(el), tf, opts);
}
function paintAll() {
document.querySelectorAll("canvas.chart-canvas").forEach(function (el) {
try {
var bars = JSON.parse(el.dataset.bars || "[]");
var tf = el.dataset.tf || "M1";
drawCandleChart(el, bars, readMarkers(el), tf);
paintCanvas(el, {});
} catch (e) {
console.warn(e);
}
});
}
function ensureLightbox() {
var box = document.getElementById("chart-lightbox");
if (box) return box;
box = document.createElement("div");
box.id = "chart-lightbox";
box.className = "chart-lightbox";
box.hidden = true;
box.innerHTML =
'<div class="chart-lightbox-panel">' +
'<div class="chart-lightbox-head">' +
'<span class="chart-lightbox-title"></span>' +
'<button type="button" class="chart-lightbox-close" aria-label="关闭">×</button>' +
"</div>" +
'<p class="muted chart-lightbox-hint">围绕进出场收紧显示 · Esc / 点击遮罩关闭</p>' +
'<canvas class="chart-lightbox-canvas"></canvas>' +
"</div>";
document.body.appendChild(box);
box.addEventListener("click", function (e) {
if (e.target === box) closeLightbox();
});
box
.querySelector(".chart-lightbox-close")
.addEventListener("click", closeLightbox);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !box.hidden) closeLightbox();
});
return box;
}
function closeLightbox() {
var box = document.getElementById("chart-lightbox");
if (box) box.hidden = true;
}
function openLightbox(srcCanvas) {
var box = ensureLightbox();
var tf = srcCanvas.dataset.tf || "M1";
var markers = readMarkers(srcCanvas);
var title = box.querySelector(".chart-lightbox-title");
title.textContent =
tf +
" · " +
(markers.side ? sideLabel(markers.side) + " · " : "") +
"点击放大";
var canvas = box.querySelector(".chart-lightbox-canvas");
canvas.dataset.tf = tf;
canvas.dataset.bars = srcCanvas.dataset.bars;
canvas.dataset.openTs = srcCanvas.dataset.openTs || "";
canvas.dataset.closeTs = srcCanvas.dataset.closeTs || "";
canvas.dataset.openPrice = srcCanvas.dataset.openPrice || "";
canvas.dataset.closePrice = srcCanvas.dataset.closePrice || "";
canvas.dataset.side = srcCanvas.dataset.side || "";
box.hidden = false;
// 等面板可见后再量宽高
requestAnimationFrame(function () {
try {
drawCandleChart(
canvas,
JSON.parse(canvas.dataset.bars || "[]"),
markers,
tf,
{ zoom: true, maxN: TF_ZOOM[tf] || 60 }
);
} catch (e) {
console.warn(e);
}
});
}
document.addEventListener("click", function (e) {
var canvas = e.target.closest && e.target.closest("canvas.chart-canvas");
if (!canvas) return;
openLightbox(canvas);
});
paintAll();
window.addEventListener("resize", paintAll);
window.addEventListener("resize", function () {
paintAll();
var box = document.getElementById("chart-lightbox");
if (box && !box.hidden) {
var canvas = box.querySelector(".chart-lightbox-canvas");
if (canvas && canvas.dataset.bars) {
try {
drawCandleChart(
canvas,
JSON.parse(canvas.dataset.bars || "[]"),
readMarkers(canvas),
canvas.dataset.tf || "M1",
{ zoom: true, maxN: TF_ZOOM[canvas.dataset.tf] || 60 }
);
} catch (err) {
/* ignore */
}
}
}
});
})();