Files
gold_monitor/app/static/js/charts.js
T
2026-10-22 13:09:09 +08:00

358 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 复盘四周期:进出场落在包含时刻的 K 线;点击可放大 */
(function () {
var TF_MAX = { M1: 500, M5: 200, M15: 100, H1: 50 };
// 放大只把画布变大,根数与缩略图同一窗口(不再额外收紧)
var TF_SEC = { M1: 60, M5: 300, M15: 900, H1: 3600 };
function num(v) {
if (v == null || v === "") return null;
var n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** 找到覆盖 ts 的那根 K 线:barOpen <= ts < barOpen+period */
function barIndexAt(bars, ts, tf) {
if (ts == null || !bars.length) return -1;
var period = TF_SEC[tf] || 60;
var t = Number(ts);
var i;
for (i = 0; i < bars.length; i++) {
var open = Number(bars[i].ts);
var end = open + period;
var last = i === bars.length - 1;
if (t >= open && (t < end || (last && t <= end))) {
return i;
}
}
if (t < Number(bars[0].ts)) return 0;
return bars.length - 1;
}
/** 始终以成交为中心裁剪到 maxN,避免短单淹没在全天 K 线里 */
function cropBars(bars, markers, tf, maxN) {
if (!bars.length) 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 =
markers.closeTs != null ? barIndexAt(bars, markers.closeTs, tf) : a;
if (a < 0) a = Math.floor(bars.length / 2);
if (b < 0) b = a;
var lo = Math.min(a, b);
var hi = Math.max(a, b);
var span = hi - lo + 1;
var pad = Math.max(4, Math.floor((maxN - span) / 2));
var start = Math.max(0, lo - pad);
var end = Math.min(bars.length, start + maxN);
start = Math.max(0, end - maxN);
return bars.slice(start, end);
}
function fmtBjt(ts) {
if (ts == null) return "";
var d = new Date(Number(ts) * 1000);
var utc = d.getTime() + d.getTimezoneOffset() * 60000;
var bjt = new Date(utc + 8 * 3600000);
var hh = String(bjt.getHours()).padStart(2, "0");
var mm = String(bjt.getMinutes()).padStart(2, "0");
return hh + ":" + mm;
}
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;
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 || opts.width || 400;
var cssH = canvas.clientHeight || opts.height || 220;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
var ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var padL = 4;
var padR = 4;
var padT = 4;
var padB = 16;
var min = Infinity;
var max = -Infinity;
bars.forEach(function (b) {
min = Math.min(min, b.low);
max = Math.max(max, b.high);
});
if (markers.openPrice != null) {
min = Math.min(min, markers.openPrice);
max = Math.max(max, markers.openPrice);
}
if (markers.closePrice != null) {
min = Math.min(min, markers.closePrice);
max = Math.max(max, markers.closePrice);
}
if (!(max > min)) max = min + 1;
var padPrice = (max - min) * 0.06 || 0.5;
min -= padPrice;
max += padPrice;
var w = cssW - padL - padR;
var h = cssH - padT - padB;
var n = bars.length;
var slot = w / n;
ctx.clearRect(0, 0, cssW, cssH);
ctx.fillStyle = "#0e0e18";
ctx.fillRect(0, 0, cssW, cssH);
var yOf = function (p) {
return padT + ((max - p) / (max - min)) * h;
};
var xOf = function (i) {
return padL + i * slot + slot * 0.5;
};
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;
ctx.strokeStyle = up ? "#0ecb81" : "#f6465d";
ctx.fillStyle = ctx.strokeStyle;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, yOf(b.high));
ctx.lineTo(x, yOf(b.low));
ctx.stroke();
var y1 = yOf(b.open);
var y2 = yOf(b.close);
var top = Math.min(y1, y2);
var bh = Math.max(1, Math.abs(y2 - y1));
ctx.fillRect(x - bodyW / 2, top, bodyW, bh);
});
ctx.fillStyle = "#888899";
ctx.font = "10px Microsoft YaHei UI, sans-serif";
ctx.textBaseline = "top";
ctx.textAlign = "left";
ctx.fillText(fmtBjt(bars[0].ts), padL, cssH - padB + 2);
ctx.textAlign = "right";
ctx.fillText(fmtBjt(bars[n - 1].ts), cssW - padR, cssH - padB + 2);
var entryColor = "#4cc2ff";
var exitColor = "#f0b429";
var entryIdx = barIndexAt(bars, markers.openTs, tf);
var exitIdx =
markers.closeTs != null ? barIndexAt(bars, markers.closeTs, tf) : -1;
var hasExit =
markers.closeTs != null &&
markers.closePrice != null &&
exitIdx >= 0;
function drawDot(i, price, color) {
if (i < 0 || price == null) return { x: 0, y: 0 };
var x = xOf(i);
var y = yOf(price);
ctx.beginPath();
ctx.arc(x, y, opts.zoom ? 6 : 4.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = "#ffffff";
ctx.stroke();
return { x: x, y: y };
}
var pe = null;
var px = null;
if (entryIdx >= 0 && markers.openPrice != null) {
pe = drawDot(entryIdx, markers.openPrice, entryColor);
}
if (hasExit) {
if (Math.abs(exitIdx - entryIdx) <= 1) {
var x = xOf(exitIdx) + Math.max(6, slot * 0.8);
var y = yOf(markers.closePrice);
if (pe && Math.abs(y - pe.y) < 6) {
y = pe.y - 8;
}
ctx.beginPath();
ctx.arc(x, y, opts.zoom ? 6 : 4.5, 0, Math.PI * 2);
ctx.fillStyle = exitColor;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = "#ffffff";
ctx.stroke();
px = { x: x, y: y };
} else {
px = drawDot(exitIdx, markers.closePrice, exitColor);
}
}
ctx.font = "bold 10px Microsoft YaHei UI, sans-serif";
if (pe) {
ctx.fillStyle = entryColor;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText("入", pe.x, pe.y - 6);
}
if (px) {
ctx.fillStyle = exitColor;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText("出", px.x, px.y - 6);
}
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;
}
}
function readMarkers(el) {
return {
openTs: num(el.dataset.openTs),
closeTs: num(el.dataset.closeTs),
openPrice: num(el.dataset.openPrice),
closePrice: num(el.dataset.closePrice),
side: el.dataset.side || "",
};
}
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 {
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_MAX[tf] || 100 }
);
} 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", 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_MAX[canvas.dataset.tf] || 100 }
);
} catch (err) {
/* ignore */
}
}
}
});
})();